├── _example ├── _previews │ ├── README.md │ └── click.mp4 ├── click │ └── click.go ├── hover │ └── hover.go ├── callback │ └── callback.go ├── preempt │ └── preempt.go ├── clock │ └── clock.go ├── local_render │ └── local_render.go ├── url_params │ └── url_params.go ├── file_upload │ └── file_upload.go ├── animation │ └── animation.go ├── session │ └── session.go ├── todo │ └── todo.go ├── initial_sync │ └── initial_sync.go └── pubsub │ └── pubsub.go ├── _tutorial └── helloworld │ ├── img │ ├── step1.png │ └── step2.gif │ └── helloworld.go ├── hlivetest ├── README.md ├── pages │ ├── click.go │ ├── harness_test.go │ └── click_test.go ├── server.go ├── browser.go ├── ack.js ├── hlivetest.go └── ack.go ├── hlivekit ├── redirect.js ├── scrollTop.js ├── cache.go ├── focus.js ├── preemptDisableOnClick.js ├── diffapply.js ├── redirect.go ├── harness_test.go ├── componentGetNodes.go ├── focus.go ├── scrollTop.go ├── README.md ├── focus_test.go ├── diffapply.go ├── preemptDisableOnClick.go ├── componentListSimple.go ├── componentList.go ├── pubsub_test.go └── pubsub.go ├── pageOption.go ├── preventDefault.js ├── Makefile ├── stopPropagation.js ├── page_test.go ├── .golangci.yml ├── preventDefault.go ├── stopPropagation.go ├── event_test.go ├── dom.go ├── systemtests ├── harness_test.go ├── unmount_test.go ├── browsers_test.go ├── addRemove_test.go ├── events_test.go └── head_test.go ├── hlive_test.go ├── tag_data_test.go ├── go.mod ├── LICENSE.txt ├── logger.go ├── pkg.go ├── cache.go ├── event.go ├── component_test.go ├── componentMountable.go ├── tag_test.go ├── pageSessionStore.go ├── pageServer.go ├── renderer_test.go ├── differ_test.go ├── attribute.go ├── component.go ├── pipelineProcessor.go ├── pageSession.go ├── renderer.go ├── hlive.go ├── attribute_test.go ├── go.sum └── pipeline.go /_example/_previews/README.md: -------------------------------------------------------------------------------- 1 | Videos that are uploaded to GitHub are backed up here. 2 | -------------------------------------------------------------------------------- /_example/_previews/click.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamHennessy/hlive/HEAD/_example/_previews/click.mp4 -------------------------------------------------------------------------------- /_tutorial/helloworld/img/step1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamHennessy/hlive/HEAD/_tutorial/helloworld/img/step1.png -------------------------------------------------------------------------------- /_tutorial/helloworld/img/step2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SamHennessy/hlive/HEAD/_tutorial/helloworld/img/step2.gif -------------------------------------------------------------------------------- /hlivetest/README.md: -------------------------------------------------------------------------------- 1 | # HLive Browser Testing Suite 2 | 3 | ## Features 4 | 5 | - Test pages in a browser using Playwright 6 | - Easy 7 | -------------------------------------------------------------------------------- /hlivekit/redirect.js: -------------------------------------------------------------------------------- 1 | // Client side redirect 2 | // Register plugin 3 | if (hlive.afterMessage.get("hredi") === undefined) { 4 | hlive.afterMessage.set("hredi", function () { 5 | document.querySelectorAll("[data-redirect]").forEach(function (el) { 6 | window.location.replace(el.getAttribute("data-redirect")); 7 | }); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /hlivekit/scrollTop.js: -------------------------------------------------------------------------------- 1 | // Set scrollTop 2 | // Register plugin 3 | if (hlive.afterMessage.get("hscrollTop") === undefined) { 4 | hlive.afterMessage.set("hscrollTop", function () { 5 | document.querySelectorAll("[data-scrollTop]").forEach(function (el) { 6 | el.scrollTop = Number(el.getAttribute("data-scrollTop")); 7 | }); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /pageOption.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import "github.com/cornelk/hashmap" 4 | 5 | type PageOption func(*Page) 6 | 7 | func PageOptionCache(cache Cache) func(*Page) { 8 | return func(p *Page) { 9 | p.cache = cache 10 | } 11 | } 12 | 13 | func PageOptionEventBindingCache(m *hashmap.Map[string, *EventBinding]) func(*Page) { 14 | return func(page *Page) { 15 | page.eventBindings = m 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /preventDefault.js: -------------------------------------------------------------------------------- 1 | // Prevent Default 2 | // Register plugin 3 | if (hlive.beforeSendEvent.get("hpd") === undefined) { 4 | hlive.beforeSendEvent.set("hpd", function (e, msg) { 5 | if (!e.currentTarget || !e.currentTarget.hasAttribute) { 6 | return msg; 7 | } 8 | 9 | if (e.currentTarget.hasAttribute("data-hlive-pd")) { 10 | e.preventDefault(); 11 | } 12 | 13 | return msg; 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Developer helpers 2 | 3 | .PHONY: test 4 | test: 5 | go test ./... 6 | 7 | .PHONY: install-test 8 | install-test: 9 | go run github.com/playwright-community/playwright-go/cmd/playwright install --with-deps 10 | 11 | .PHONY: install-dev 12 | install-dev: 13 | go install mvdan.cc/gofumpt@latest 14 | 15 | .PHONY: format 16 | format: 17 | go mod tidy 18 | gofumpt -l -w ./ 19 | 20 | # Run static code analysis 21 | .PHONY: lint 22 | lint: 23 | golangci-lint run 24 | -------------------------------------------------------------------------------- /stopPropagation.js: -------------------------------------------------------------------------------- 1 | // Stop Propagation 2 | // Register plugin 3 | if (hlive.beforeSendEvent.get("hsp") === undefined) { 4 | hlive.beforeSendEvent.set("hsp", function (e, msg) { 5 | if (!e.currentTarget || !e.currentTarget.getAttribute) { 6 | return msg; 7 | } 8 | 9 | if (!e.currentTarget.hasAttribute("data-hlive-sp")) { 10 | return msg; 11 | } 12 | 13 | e.stopPropagation() 14 | 15 | return msg; 16 | }) 17 | } -------------------------------------------------------------------------------- /page_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | ) 9 | 10 | func TestPage_CloseHooks(t *testing.T) { 11 | t.Parallel() 12 | 13 | p := l.NewPage() 14 | 15 | var called bool 16 | 17 | hook := func(ctx context.Context, page *l.Page) { 18 | called = true 19 | } 20 | 21 | p.HookCloseAdd(hook) 22 | 23 | p.Close(context.Background()) 24 | 25 | if !called { 26 | t.Error("close hook not called") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # golangci.com configuration 2 | # https://github.com/golangci/golangci/wiki/Configuration 3 | 4 | linters: 5 | enable-all: true 6 | disable: 7 | - exhaustivestruct 8 | - exhaustruct 9 | - wsl 10 | - varnamelen 11 | # Deprecated 12 | - interfacer 13 | - maligned 14 | - scopelint 15 | - golint 16 | 17 | output: 18 | sort-results: true 19 | 20 | service: 21 | golangci-lint-version: 1.46.x # use the fixed version to not introduce new linters unexpectedly 22 | -------------------------------------------------------------------------------- /hlivekit/cache.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import "github.com/dgraph-io/ristretto" 4 | 5 | func NewCacheRistretto(cache *ristretto.Cache) *CacheRistretto { 6 | return &CacheRistretto{cache: cache} 7 | } 8 | 9 | // CacheRistretto cache adapter for Ristretto 10 | type CacheRistretto struct { 11 | cache *ristretto.Cache 12 | } 13 | 14 | func (c *CacheRistretto) Get(key any) (any, bool) { 15 | return c.cache.Get(key) 16 | } 17 | 18 | func (c *CacheRistretto) Set(key any, value any) { 19 | c.cache.Set(key, value, 0) 20 | } 21 | -------------------------------------------------------------------------------- /hlivekit/focus.js: -------------------------------------------------------------------------------- 1 | // Give focus 2 | // Register plugin 3 | if (hlive.afterMessage.get("hfocue") === undefined) { 4 | hlive.afterMessage.set("hfocus", function () { 5 | document.querySelectorAll("[data-hlive-focus]").forEach(function (el) { 6 | el.focus(); 7 | if (el.selectionStart !== undefined) { 8 | setTimeout(function () { 9 | el.selectionStart = el.selectionEnd = 10000; 10 | }, 0); 11 | } 12 | }); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /hlivekit/preemptDisableOnClick.js: -------------------------------------------------------------------------------- 1 | // HLive Preempt Disable 2 | function hlivePreDisable (e) { 3 | if (!e.currentTarget) { 4 | return 5 | } 6 | 7 | const el = e.currentTarget; 8 | el.setAttribute("disabled", ""); 9 | } 10 | 11 | if (hlive.afterMessage.get("hpreDis") === undefined) { 12 | hlive.afterMessage.set("hpreDis", function () { 13 | document.querySelectorAll("[data-hlive-pre-disable]").forEach(function (el) { 14 | el.addEventListener(el.getAttribute("data-hlive-pre-disable"), hlivePreDisable) 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /hlivetest/pages/click.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "context" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | ) 8 | 9 | func Click() func() *l.Page { 10 | return func() *l.Page { 11 | page := l.NewPage() 12 | page.DOM().Title().Add("Test Ack Click") 13 | 14 | count := l.Box(0) 15 | 16 | page.DOM().Body().Add( 17 | l.C("button", 18 | l.Attrs{"id": "btn"}, 19 | l.On("click", func(_ context.Context, _ l.Event) { 20 | count.Lock(func(val int) int { 21 | return val + 1 22 | }) 23 | }), 24 | "Click", 25 | ), 26 | l.T("div", l.Attrs{"id": "count"}, count), 27 | ) 28 | 29 | return page 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /hlivekit/diffapply.js: -------------------------------------------------------------------------------- 1 | // Trigger diffapply, should always be last 2 | function diffApply() { 3 | document.querySelectorAll("[hon*=diffapply]").forEach(function (el) { 4 | const ids = hlive.getEventHandlerIDs(el); 5 | 6 | if (!ids["diffapply"]) { 7 | return; 8 | } 9 | 10 | for (let i = 0; i < ids["diffapply"].length; i++) { 11 | hlive.sendMsg({ 12 | t: "e", 13 | i: ids["diffapply"][i], 14 | }); 15 | } 16 | }); 17 | } 18 | 19 | // Register plugin 20 | if (hlive.afterMessage.get("hdiffApply") === undefined) { 21 | hlive.afterMessage.set("hdiffApply", diffApply); 22 | } -------------------------------------------------------------------------------- /hlivetest/server.go: -------------------------------------------------------------------------------- 1 | package hlivetest 2 | 3 | import ( 4 | "net/http/httptest" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | ) 8 | 9 | type Server struct { 10 | PageServer *l.PageServer 11 | PageSessionStore *l.PageSessionStore 12 | HTTPServer *httptest.Server 13 | } 14 | 15 | func NewServer(pageFn func() *l.Page) *Server { 16 | s := &Server{} 17 | s.PageSessionStore = l.NewPageSessionStore() 18 | s.PageServer = l.NewPageServerWithSessionStore(addAck(pageFn), s.PageSessionStore) 19 | s.HTTPServer = httptest.NewServer(s.PageServer) 20 | 21 | return s 22 | } 23 | 24 | func addAck(pageFn func() *l.Page) func() *l.Page { 25 | return func() *l.Page { 26 | p := pageFn() 27 | 28 | p.DOM().HTML().Add(Ack()) 29 | 30 | return p 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /preventDefault.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | const PreventDefaultAttributeName = "data-hlive-pd" 8 | 9 | //go:embed preventDefault.js 10 | var PreventDefaultJavaScript []byte 11 | 12 | func PreventDefault() *PreventDefaultAttribute { 13 | attr := &PreventDefaultAttribute{ 14 | NewAttribute(PreventDefaultAttributeName, ""), 15 | } 16 | 17 | return attr 18 | } 19 | 20 | func PreventDefaultRemove(tag Adder) { 21 | tag.Add(AttrsOff{PreventDefaultAttributeName}) 22 | } 23 | 24 | type PreventDefaultAttribute struct { 25 | *Attribute 26 | } 27 | 28 | func (a *PreventDefaultAttribute) Initialize(page *Page) { 29 | page.DOM().Head().Add(T("script", HTML(PreventDefaultJavaScript))) 30 | } 31 | 32 | func (a *PreventDefaultAttribute) InitializeSSR(page *Page) { 33 | page.DOM().Head().Add(T("script", HTML(PreventDefaultJavaScript))) 34 | } 35 | -------------------------------------------------------------------------------- /stopPropagation.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | const StopPropagationAttributeName = "data-hlive-sp" 8 | 9 | //go:embed stopPropagation.js 10 | var StopPropagationJavaScript []byte 11 | 12 | func StopPropagation() Attributer { 13 | attr := &StopPropagationAttribute{ 14 | NewAttribute(StopPropagationAttributeName, ""), 15 | } 16 | 17 | return attr 18 | } 19 | 20 | func StopPropagationRemove(tag Adder) { 21 | tag.Add(AttrsOff{StopPropagationAttributeName}) 22 | } 23 | 24 | type StopPropagationAttribute struct { 25 | *Attribute 26 | } 27 | 28 | func (a *StopPropagationAttribute) Initialize(page *Page) { 29 | page.DOM().Head().Add(T("script", HTML(StopPropagationJavaScript))) 30 | } 31 | 32 | func (a *StopPropagationAttribute) InitializeSSR(page *Page) { 33 | page.DOM().Head().Add(T("script", HTML(StopPropagationJavaScript))) 34 | } 35 | -------------------------------------------------------------------------------- /hlivekit/redirect.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | _ "embed" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | ) 8 | 9 | const RedirectAttributeName = "data-redirect" 10 | 11 | //go:embed redirect.js 12 | var RedirectJavaScript []byte 13 | 14 | func Redirect(url string) l.Attributer { 15 | attr := &RedirectAttribute{ 16 | Attribute: l.NewAttribute(RedirectAttributeName, url), 17 | } 18 | 19 | return attr 20 | } 21 | 22 | type RedirectAttribute struct { 23 | *l.Attribute 24 | 25 | rendered bool 26 | } 27 | 28 | func (a *RedirectAttribute) Initialize(page *l.Page) { 29 | if a.rendered { 30 | return 31 | } 32 | 33 | page.DOM().Head().Add(l.T("script", l.HTML(RedirectJavaScript))) 34 | } 35 | 36 | func (a *RedirectAttribute) InitializeSSR(page *l.Page) { 37 | a.rendered = true 38 | page.DOM().Head().Add(l.T("script", l.HTML(RedirectJavaScript))) 39 | } 40 | -------------------------------------------------------------------------------- /hlivekit/harness_test.go: -------------------------------------------------------------------------------- 1 | package hlivekit_test 2 | 3 | import ( 4 | "testing" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | "github.com/SamHennessy/hlive/hlivetest" 8 | "github.com/playwright-community/playwright-go" 9 | ) 10 | 11 | type harness struct { 12 | server *hlivetest.Server 13 | pwpage playwright.Page 14 | teardown func() 15 | } 16 | 17 | func setup(t *testing.T, pageFn func() *l.Page) harness { 18 | t.Helper() 19 | 20 | if testing.Short() { 21 | t.Skip("skipping test in short mode.") 22 | } 23 | 24 | h := harness{ 25 | server: hlivetest.NewServer(pageFn), 26 | pwpage: hlivetest.NewBrowserPage(), 27 | } 28 | 29 | h.teardown = func() { 30 | if err := h.pwpage.Close(); err != nil { 31 | t.Error(err) 32 | } 33 | } 34 | 35 | if _, err := h.pwpage.Goto(h.server.HTTPServer.URL); err != nil { 36 | t.Fatal("goto page:", err) 37 | } 38 | 39 | return h 40 | } 41 | -------------------------------------------------------------------------------- /hlivetest/pages/harness_test.go: -------------------------------------------------------------------------------- 1 | package pages_test 2 | 3 | import ( 4 | "testing" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | "github.com/SamHennessy/hlive/hlivetest" 8 | "github.com/playwright-community/playwright-go" 9 | ) 10 | 11 | type harness struct { 12 | server *hlivetest.Server 13 | pwpage playwright.Page 14 | teardown func() 15 | } 16 | 17 | func setup(t *testing.T, pageFn func() *l.Page) harness { 18 | t.Helper() 19 | 20 | if testing.Short() { 21 | t.Skip("skipping test in short mode.") 22 | } 23 | 24 | h := harness{ 25 | server: hlivetest.NewServer(pageFn), 26 | pwpage: hlivetest.NewBrowserPage(), 27 | } 28 | 29 | h.teardown = func() { 30 | if err := h.pwpage.Close(); err != nil { 31 | t.Error(err) 32 | } 33 | } 34 | 35 | if _, err := h.pwpage.Goto(h.server.HTTPServer.URL); err != nil { 36 | t.Fatal("goto page:", err) 37 | } 38 | 39 | return h 40 | } 41 | -------------------------------------------------------------------------------- /hlivekit/componentGetNodes.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | "github.com/SamHennessy/hlive" 5 | ) 6 | 7 | // ComponentGetNodes add a custom GetNodes function to ComponentMountable 8 | type ComponentGetNodes struct { 9 | *hlive.ComponentMountable 10 | 11 | GetNodesFunc func() *hlive.NodeGroup 12 | } 13 | 14 | // CGN is a shortcut for NewComponentGetNodes 15 | func CGN(name string, getNodesFunc func() *hlive.NodeGroup, elements ...any) *ComponentGetNodes { 16 | return NewComponentGetNodes(name, getNodesFunc, elements...) 17 | } 18 | 19 | func NewComponentGetNodes(name string, getNodesFunc func() *hlive.NodeGroup, elements ...any) *ComponentGetNodes { 20 | return &ComponentGetNodes{ 21 | ComponentMountable: hlive.NewComponentMountable(name, elements...), 22 | GetNodesFunc: getNodesFunc, 23 | } 24 | } 25 | 26 | func (c *ComponentGetNodes) GetNodes() *hlive.NodeGroup { 27 | return c.GetNodesFunc() 28 | } 29 | -------------------------------------------------------------------------------- /hlivekit/focus.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | _ "embed" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | ) 8 | 9 | const FocusAttributeName = "data-hlive-focus" 10 | 11 | //go:embed focus.js 12 | var FocusJavaScript []byte 13 | 14 | func Focus() l.Attributer { 15 | attr := &FocusAttribute{ 16 | Attribute: l.NewAttribute(FocusAttributeName, ""), 17 | } 18 | 19 | return attr 20 | } 21 | 22 | func FocusRemove(tag l.Adder) { 23 | tag.Add(l.AttrsOff{FocusAttributeName}) 24 | } 25 | 26 | type FocusAttribute struct { 27 | *l.Attribute 28 | 29 | rendered bool 30 | } 31 | 32 | func (a *FocusAttribute) Initialize(page *l.Page) { 33 | if a.rendered { 34 | return 35 | } 36 | 37 | page.DOM().Head().Add(l.T("script", l.HTML(FocusJavaScript))) 38 | } 39 | 40 | func (a *FocusAttribute) InitializeSSR(page *l.Page) { 41 | a.rendered = true 42 | page.DOM().Head().Add(l.T("script", l.HTML(FocusJavaScript))) 43 | } 44 | -------------------------------------------------------------------------------- /hlivetest/browser.go: -------------------------------------------------------------------------------- 1 | package hlivetest 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/playwright-community/playwright-go" 8 | ) 9 | 10 | var ( 11 | browserOnce sync.Once 12 | pwContext playwright.BrowserContext 13 | ) 14 | 15 | func NewBrowserPage() playwright.Page { 16 | browserOnce.Do(func() { 17 | pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}) 18 | if err != nil { 19 | panic(fmt.Errorf("launch playwrite: %w", err)) 20 | } 21 | 22 | browser, err := pw.Chromium.Launch() 23 | if err != nil { 24 | panic(fmt.Errorf("launch Chromium: %w", err)) 25 | } 26 | 27 | pwContext, err = browser.NewContext() 28 | if err != nil { 29 | panic(fmt.Errorf("playwrite browser context: %w", err)) 30 | } 31 | }) 32 | 33 | page, err := pwContext.NewPage() 34 | if err != nil { 35 | panic(fmt.Errorf("playwrite context new page: %w", err)) 36 | } 37 | 38 | return page 39 | } 40 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | "github.com/go-test/deep" 9 | ) 10 | 11 | func TestEvent_OnHandler(t *testing.T) { 12 | t.Parallel() 13 | 14 | var val bool 15 | 16 | eb := l.On("input", func(_ context.Context, _ l.Event) { 17 | val = true 18 | }) 19 | 20 | if val { 21 | t.Fatal("handler call before expected") 22 | } 23 | 24 | eb.Handler(nil, l.Event{}) 25 | 26 | if !val { 27 | t.Error("unexpected handler") 28 | } 29 | } 30 | 31 | func TestEvent_On(t *testing.T) { 32 | t.Parallel() 33 | 34 | eb := l.On("INPUT", nil) 35 | 36 | if eb.Once { 37 | t.Error("once not default to false") 38 | } 39 | 40 | if diff := deep.Equal("input", eb.Name); diff != nil { 41 | t.Error(diff) 42 | } 43 | } 44 | 45 | func TestEvent_OnOnce(t *testing.T) { 46 | t.Parallel() 47 | 48 | if eb := l.OnOnce("input", nil); !eb.Once { 49 | t.Error("once not default to true") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /hlivetest/pages/click_test.go: -------------------------------------------------------------------------------- 1 | package pages_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/SamHennessy/hlive/hlivetest" 7 | "github.com/SamHennessy/hlive/hlivetest/pages" 8 | ) 9 | 10 | func TestClick_OneClick(t *testing.T) { 11 | t.Parallel() 12 | 13 | h := setup(t, pages.Click()) 14 | defer h.teardown() 15 | 16 | hlivetest.Diff(t, "0", hlivetest.TextContent(t, h.pwpage, "#count")) 17 | 18 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 19 | 20 | hlivetest.Diff(t, "1", hlivetest.TextContent(t, h.pwpage, "#count")) 21 | } 22 | 23 | func TestClick_TenClick(t *testing.T) { 24 | t.Parallel() 25 | 26 | h := setup(t, pages.Click()) 27 | defer h.teardown() 28 | 29 | hlivetest.Diff(t, "0", hlivetest.TextContent(t, h.pwpage, "#count")) 30 | 31 | for i := 0; i < 9; i++ { 32 | hlivetest.Click(t, h.pwpage, "#btn") 33 | } 34 | 35 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 36 | 37 | hlivetest.Diff(t, "10", hlivetest.TextContent(t, h.pwpage, "#count")) 38 | } 39 | -------------------------------------------------------------------------------- /dom.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | type DOM struct { 4 | // Root DOM elements 5 | docType HTML 6 | html Adder 7 | head Adder 8 | meta Adder 9 | title Adder 10 | body Adder 11 | } 12 | 13 | func NewDOM() DOM { 14 | dom := DOM{ 15 | docType: HTML5DocType, 16 | html: C("html", Attrs{"lang": "en"}), 17 | head: C("head"), 18 | meta: T("meta", Attrs{"charset": "utf-8"}), 19 | title: C("title"), 20 | body: C("body"), 21 | } 22 | 23 | dom.head.Add(dom.meta, dom.title) 24 | dom.html.Add(dom.head, dom.body) 25 | 26 | return dom 27 | } 28 | 29 | func (dom DOM) DocType() HTML { 30 | return dom.docType 31 | } 32 | 33 | func (dom DOM) HTML() Adder { 34 | return dom.html 35 | } 36 | 37 | func (dom DOM) Head() Adder { 38 | return dom.head 39 | } 40 | 41 | func (dom DOM) Meta() Adder { 42 | return dom.meta 43 | } 44 | 45 | func (dom DOM) Title() Adder { 46 | return dom.title 47 | } 48 | 49 | func (dom DOM) Body() Adder { 50 | return dom.body 51 | } 52 | -------------------------------------------------------------------------------- /systemtests/harness_test.go: -------------------------------------------------------------------------------- 1 | package systemtests_test 2 | 3 | import ( 4 | "testing" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | "github.com/SamHennessy/hlive/hlivetest" 8 | "github.com/playwright-community/playwright-go" 9 | ) 10 | 11 | type harness struct { 12 | server *hlivetest.Server 13 | pwpage playwright.Page 14 | teardown func() 15 | } 16 | 17 | func setup(t *testing.T, pageFn func() *l.Page) harness { 18 | t.Helper() 19 | 20 | if testing.Short() { 21 | t.Skip("skipping test in short mode.") 22 | } 23 | 24 | h := harness{ 25 | server: hlivetest.NewServer(pageFn), 26 | pwpage: hlivetest.NewBrowserPage(), 27 | } 28 | 29 | h.teardown = func() { 30 | if !h.pwpage.IsClosed() { 31 | if err := h.pwpage.Close(); err != nil { 32 | t.Error(err) 33 | } 34 | } 35 | 36 | h.server.PageSessionStore.Done <- true 37 | } 38 | 39 | if _, err := h.pwpage.Goto(h.server.HTTPServer.URL); err != nil { 40 | t.Fatal("goto page:", err) 41 | } 42 | 43 | return h 44 | } 45 | -------------------------------------------------------------------------------- /hlivekit/scrollTop.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | _ "embed" 5 | "strconv" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | ) 9 | 10 | const ScrollTopAttributeName = "data-scrollTop" 11 | 12 | //go:embed scrollTop.js 13 | var ScrollTopJavaScript []byte 14 | 15 | func ScrollTop(val int) l.Attributer { 16 | attr := &ScrollTopAttribute{ 17 | Attribute: l.NewAttribute(ScrollTopAttributeName, strconv.Itoa(val)), 18 | } 19 | 20 | return attr 21 | } 22 | 23 | func ScrollTopRemove(tag l.Adder) { 24 | tag.Add(l.AttrsOff{ScrollTopAttributeName}) 25 | } 26 | 27 | type ScrollTopAttribute struct { 28 | *l.Attribute 29 | 30 | rendered bool 31 | } 32 | 33 | func (a *ScrollTopAttribute) Initialize(page *l.Page) { 34 | if a.rendered { 35 | return 36 | } 37 | 38 | page.DOM().Head().Add(l.T("script", l.HTML(ScrollTopJavaScript))) 39 | } 40 | 41 | func (a *ScrollTopAttribute) InitializeSSR(page *l.Page) { 42 | a.rendered = true 43 | page.DOM().Head().Add(l.T("script", l.HTML(ScrollTopJavaScript))) 44 | } 45 | -------------------------------------------------------------------------------- /hlive_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/SamHennessy/hlive" 7 | ) 8 | 9 | func TestIsValidElement(t *testing.T) { 10 | t.Parallel() 11 | 12 | type args struct { 13 | el any 14 | } 15 | 16 | tests := []struct { 17 | name string 18 | args args 19 | want bool 20 | }{ 21 | {"bool", args{true}, false}, 22 | {"nil", args{nil}, true}, 23 | {"string", args{"test"}, true}, 24 | {"html", args{hlive.HTML("

title

")}, true}, 25 | {"tag", args{hlive.T("h1")}, true}, 26 | {"css", args{hlive.ClassBool{"c1": true}}, true}, 27 | {"attribute", args{hlive.AttrsOff{"disabled"}}, true}, 28 | {"attrs", args{hlive.Attrs{"href": "https://foo.com"}}, true}, 29 | {"component", args{hlive.C("span")}, true}, 30 | } 31 | 32 | for _, tt := range tests { 33 | tt := tt 34 | 35 | t.Run(tt.name, func(t *testing.T) { 36 | t.Parallel() 37 | if got := hlive.IsElement(tt.args.el); got != tt.want { 38 | t.Errorf("IsElement() = %v, want %v", got, tt.want) 39 | } 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tag_data_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import l "github.com/SamHennessy/hlive" 4 | 5 | type testTagger struct{} 6 | 7 | func (t *testTagger) GetName() string { 8 | return "" 9 | } 10 | 11 | func (t *testTagger) GetAttributes() []l.Attributer { 12 | return nil 13 | } 14 | 15 | func (t *testTagger) GetNodes() *l.NodeGroup { 16 | return nil 17 | } 18 | 19 | func (t *testTagger) IsVoid() bool { 20 | return false 21 | } 22 | 23 | func (t *testTagger) IsNil() bool { 24 | return t == nil 25 | } 26 | 27 | type testUniqueTagger struct { 28 | testTagger 29 | } 30 | 31 | func (t *testUniqueTagger) GetID() string { 32 | return "" 33 | } 34 | 35 | type testComponenter struct { 36 | testUniqueTagger 37 | } 38 | 39 | func (c *testComponenter) GetEventBinding(id string) *l.EventBinding { 40 | return nil 41 | } 42 | 43 | func (c *testComponenter) GetEventBindings() []*l.EventBinding { 44 | return nil 45 | } 46 | func (c *testComponenter) RemoveEventBinding(id string) {} 47 | func (c *testComponenter) IsAutoRender() bool { 48 | return false 49 | } 50 | -------------------------------------------------------------------------------- /systemtests/unmount_test.go: -------------------------------------------------------------------------------- 1 | package systemtests_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | "github.com/SamHennessy/hlive/hlivetest" 10 | ) 11 | 12 | func TestUnmount_CloseTab(t *testing.T) { 13 | t.Parallel() 14 | 15 | done := make(chan bool) 16 | 17 | c := l.CM("div") 18 | c.SetUnmount(func(ctx context.Context) { 19 | done <- true 20 | }) 21 | 22 | page := l.NewPage() 23 | 24 | page.DOM().Body().Add(c) 25 | 26 | pageFn := func() *l.Page { 27 | return page 28 | } 29 | 30 | h := setup(t, pageFn) 31 | defer h.teardown() 32 | 33 | _, err := h.pwpage.WaitForFunction("hlive.sessID != 1", nil) 34 | 35 | hlivetest.FatalOnErr(t, err) 36 | 37 | // No wait after disconnect 38 | h.server.PageSessionStore.DisconnectTimeout = 0 39 | 40 | hlivetest.FatalOnErr(t, h.pwpage.Close()) 41 | 42 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 43 | defer cancel() 44 | 45 | select { 46 | case <-done: 47 | return 48 | case <-ctx.Done(): 49 | t.Error("timed out waiting for unmount") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /hlivekit/README.md: -------------------------------------------------------------------------------- 1 | # HLiveKit 2 | 3 | A toolkit of useful functionality for HLive 4 | 5 | Features: 6 | 7 | - Focus 8 | - Give a node input focus 9 | - On Diff Apply 10 | - Call a handler after a HLive diff has been applied in the browser 11 | - Component List 12 | - Manage a dynamic list of components, for example results of a search. 13 | - Component List Simple (Advanced) 14 | - Like a Component List but without the memory cleanup logic. 15 | 16 | ## Ideas 17 | 18 | - Add client side interactions for real-time feedback 19 | - Disable (done) 20 | - Show/Hide 21 | - Added remove CSS/Style/Attribute 22 | - Disable with, disable and set the text on a button 23 | - Apply to a form when submitted? 24 | - Allow JS (Go?) function to be run locally on text input 25 | - https://stackoverflow.com/questions/30058927/format-a-phone-number-as-a-user-types-using-pure-javascript 26 | - Key binding to, for example, submit a form? 27 | - x-cloak like feature 28 | - Hide things until HLive had initialised 29 | - Copy to clipboard 30 | - `navigator.clipboard.writeText()` 31 | - Drag and drop 32 | -------------------------------------------------------------------------------- /hlivetest/ack.js: -------------------------------------------------------------------------------- 1 | // test-ack 2 | if (hlive.beforeSendEvent.get("htack") === undefined) { 3 | const hliveTestAck = { 4 | received: {}, 5 | } 6 | 7 | hlive.beforeSendEvent.set("htack", function (e, msg) { 8 | if (!e.currentTarget || !e.currentTarget.getAttribute) { 9 | return msg; 10 | } 11 | 12 | if (!e.currentTarget.getAttribute("data-hlive-test-ack-id")) { 13 | return msg; 14 | } 15 | 16 | if (!msg.e) { 17 | msg.e = {}; 18 | } 19 | 20 | msg.e["test-ack-id"] = e.currentTarget.getAttribute("data-hlive-test-ack-id"); 21 | e.currentTarget.removeAttribute("data-hlive-test-ack-id"); 22 | 23 | return msg; 24 | }) 25 | 26 | hlive.beforeProcessMessage.set("htack", function (msg) { 27 | if (msg.startsWith("ack|") === false) { 28 | return msg; 29 | } 30 | 31 | const parts = msg.split("|") 32 | console.log("ack beforeProcessMessage", parts); 33 | 34 | hliveTestAck.received[parts[1]] = true; 35 | 36 | return ""; 37 | }) 38 | } 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/SamHennessy/hlive 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/cornelk/hashmap v1.0.8 7 | github.com/dgraph-io/ristretto v0.1.0 8 | github.com/go-test/deep v1.0.8 9 | github.com/gorilla/websocket v1.5.0 10 | github.com/playwright-community/playwright-go v0.2000.1 11 | github.com/rs/xid v1.4.0 12 | github.com/rs/zerolog v1.26.1 13 | github.com/tdewolff/minify/v2 v2.12.0 14 | github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125 15 | github.com/vmihailenco/msgpack/v5 v5.3.5 16 | ) 17 | 18 | require ( 19 | github.com/cespare/xxhash/v2 v2.1.1 // indirect 20 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect 21 | github.com/dustin/go-humanize v1.0.0 // indirect 22 | github.com/go-stack/stack v1.8.1 // indirect 23 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect 24 | github.com/pkg/errors v0.9.1 // indirect 25 | github.com/tdewolff/parse/v2 v2.6.1 // indirect 26 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 27 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect 28 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sam Hennessy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /_example/click/click.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | ) 10 | 11 | func main() { 12 | http.Handle("/", l.NewPageServer(home)) 13 | 14 | log.Println("INFO: listing :3000") 15 | 16 | if err := http.ListenAndServe(":3000", nil); err != nil { 17 | log.Println("ERRO: http listen and serve: ", err) 18 | } 19 | } 20 | 21 | func home() *l.Page { 22 | page := l.NewPage() 23 | page.DOM().Title().Add("Click Example") 24 | page.DOM().Head().Add( 25 | l.T("link", l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 26 | 27 | // A thread safe value container 28 | count := l.Box(0) 29 | 30 | page.DOM().Body().Add( 31 | l.T("header", 32 | l.T("h1", "Click"), 33 | l.T("p", "Click the button and see the count increase"), 34 | ), 35 | l.T("main", 36 | l.T("p", 37 | "Clicks: ", 38 | l.C("button", 39 | l.On("click", func(_ context.Context, _ l.Event) { 40 | // We need to read and write inside a single lock 41 | count.Lock(func(v int) int { return v + 1 }) 42 | }), 43 | count, 44 | ), 45 | ), 46 | ), 47 | ) 48 | 49 | return page 50 | } 51 | -------------------------------------------------------------------------------- /systemtests/browsers_test.go: -------------------------------------------------------------------------------- 1 | package systemtests_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | "github.com/SamHennessy/hlive/hlivetest" 9 | ) 10 | 11 | // Dealing with browser behavior 12 | 13 | // While you may have 3 nodes in the Page dom if they result in 3 strings the browser will combine them into a 14 | // single node. this tests we can deal with that situation. 15 | func TestBrowser_StringConcat(t *testing.T) { 16 | t.Parallel() 17 | 18 | pageFn := func() *l.Page { 19 | count := l.Box(0) 20 | 21 | page := l.NewPage() 22 | 23 | page.DOM().Body().Add( 24 | l.T("div", l.Attrs{"id": "content"}, 25 | "The count is ", count, ".", 26 | ), 27 | l.C("button", l.Attrs{"id": "btn"}, "Click Me", 28 | l.On("click", func(ctx context.Context, e l.Event) { 29 | count.Lock(func(v int) int { 30 | return v + 1 31 | }) 32 | })), 33 | ) 34 | 35 | return page 36 | } 37 | 38 | h := setup(t, pageFn) 39 | defer h.teardown() 40 | 41 | hlivetest.Diff(t, "The count is 0.", hlivetest.TextContent(t, h.pwpage, "#content")) 42 | 43 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 44 | 45 | hlivetest.Diff(t, "The count is 1.", hlivetest.TextContent(t, h.pwpage, "#content")) 46 | } 47 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/rs/zerolog" 9 | "github.com/rs/zerolog/pkgerrors" 10 | ) 11 | 12 | // Logger is a global logger used when a logger is not available 13 | var Logger zerolog.Logger 14 | 15 | // LoggerDev is a global logger needed for developer warnings to avoid the need for panics 16 | var LoggerDev zerolog.Logger 17 | 18 | func init() { 19 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 20 | 21 | Logger = zerolog.New(os.Stderr).With().Timestamp().Logger() 22 | LoggerDev = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger() 23 | } 24 | 25 | func callerFrame(skip int) (runtime.Frame, bool) { 26 | rpc := make([]uintptr, 1) 27 | n := runtime.Callers(skip+2, rpc[:]) 28 | if n < 1 { 29 | return runtime.Frame{}, false 30 | } 31 | frame, _ := runtime.CallersFrames(rpc).Next() 32 | 33 | return frame, frame.PC != 0 34 | } 35 | 36 | func CallerStackStr() string { 37 | skip := 0 38 | stack := "" 39 | prefix := "" 40 | 41 | startFrame: 42 | 43 | skip++ 44 | frame, ok := callerFrame(skip) 45 | if ok { 46 | stack = fmt.Sprintf("%s:%v%s%s", frame.Function, frame.Line, prefix, stack) 47 | 48 | prefix = " > " 49 | goto startFrame 50 | } 51 | 52 | return stack 53 | } 54 | -------------------------------------------------------------------------------- /_example/hover/hover.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | ) 10 | 11 | func main() { 12 | http.Handle("/", l.NewPageServer(home)) 13 | 14 | log.Println("INFO: listing :3000") 15 | 16 | if err := http.ListenAndServe(":3000", nil); err != nil { 17 | log.Println("ERRO: http listen and serve: ", err) 18 | } 19 | } 20 | 21 | func home() *l.Page { 22 | hoverState := l.Box(" ") 23 | 24 | hover := l.C("h2", 25 | l.Style{"padding": "1em", "text-align": "center", "border": "solid"}, 26 | l.On("mouseEnter", func(ctx context.Context, e l.Event) { 27 | hoverState.Set("Mouse enter") 28 | }), 29 | l.On("mouseLeave", func(ctx context.Context, e l.Event) { 30 | hoverState.Set("Mouse leave") 31 | }), 32 | "Hover over me", 33 | ) 34 | 35 | page := l.NewPage() 36 | page.DOM().Title().Add("Hover Example") 37 | page.DOM().Head().Add( 38 | l.T("link", l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 39 | 40 | page.DOM().Body().Add( 41 | l.T("header", 42 | l.T("h1", "Hover"), 43 | l.T("p", "React to hover events on the server"), 44 | ), 45 | l.T("main", 46 | l.T("div", hover), 47 | l.T("hr"), 48 | l.T("pre", l.T("code", hoverState)), 49 | ), 50 | ) 51 | 52 | return page 53 | } 54 | -------------------------------------------------------------------------------- /pkg.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | // Public errors 9 | var ( 10 | ErrRenderElement = errors.New("attempted to render an unrecognised element") 11 | ) 12 | 13 | // HLive special attributes 14 | const ( 15 | AttrID = "hid" 16 | AttrOn = "hon" 17 | AttrUpload = "data-hlive-upload" 18 | base10 = 10 19 | bit32 = 32 20 | bit64 = 64 21 | ) 22 | 23 | // Defaults 24 | const ( 25 | HTML5DocType HTML = "" 26 | WebSocketDisconnectTimeoutDefault = time.Second * 5 27 | PageSessionLimitDefault = 1000 28 | PageSessionGarbageCollectionTick = time.Second 29 | ) 30 | 31 | type CtxKey string 32 | 33 | // Context keys 34 | const ( 35 | CtxRender CtxKey = "render" 36 | CtxRenderComponent CtxKey = "render_comp" 37 | ) 38 | 39 | type DiffType string 40 | 41 | // Diff types 42 | const ( 43 | DiffUpdate DiffType = "u" 44 | DiffCreate DiffType = "c" 45 | DiffDelete DiffType = "d" 46 | ) 47 | 48 | var newline = []byte{'\n'} 49 | 50 | const ( 51 | // Time allowed to write a message to the peer. 52 | writeWait = 10 * time.Second 53 | 54 | // Time allowed to read the next pong message from the peer. 55 | pongWait = 60 * time.Second 56 | 57 | // Send pings to peer with this period. Must be less than pongWait. 58 | pingPeriod = (pongWait * 9) / 10 59 | 60 | // Maximum message size allowed from peer. 61 | // maxMessageSize = 512 62 | ) 63 | -------------------------------------------------------------------------------- /_tutorial/helloworld/helloworld.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | ) 9 | 10 | // Step 1 11 | func home() *l.Page { 12 | page := l.NewPage() 13 | page.DOM().Body().Add("Hello, world.") 14 | 15 | return page 16 | } 17 | 18 | // Step 2 19 | // func home() *l.Page { 20 | // var message string 21 | // 22 | // input := l.NewComponent("input") 23 | // input.Add(l.Attrs{"type": "text"}) 24 | // input.Add(l.On("keyup", func(ctx context.Context, e l.Event) { 25 | // message = e.Value 26 | // })) 27 | // 28 | // page := l.NewPage() 29 | // page.DOM().Body().Add(l.NewTag("div", input)) 30 | // page.DOM().Body().Add(l.T("hr")) 31 | // page.DOM().Body().Add("Hello, ", &message) 32 | // 33 | // return page 34 | // } 35 | 36 | // Step 2.1 37 | // func home() *l.Page { 38 | // var message string 39 | // 40 | // input := l.C("input", 41 | // l.Attrs{"type": "text"}, 42 | // l.On("keyup", func(_ context.Context, e l.Event) { 43 | // message = e.Value 44 | // }), 45 | // ) 46 | // 47 | // page := l.NewPage() 48 | // page.DOM().Body().Add( 49 | // l.T("div", input), 50 | // l.T("hr"), 51 | // "Hello, ", &message, 52 | // ) 53 | // 54 | // return page 55 | // } 56 | 57 | func main() { 58 | http.Handle("/", l.NewPageServer(home)) 59 | 60 | log.Println("Listing on :3000") 61 | 62 | if err := http.ListenAndServe(":3000", nil); err != nil { 63 | log.Println("Error: http listen and serve:", err) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /_example/callback/callback.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | "github.com/SamHennessy/hlive/hlivekit" 10 | ) 11 | 12 | func main() { 13 | http.Handle("/", l.NewPageServer(home)) 14 | 15 | log.Println("INFO: listing :3000") 16 | 17 | if err := http.ListenAndServe(":3000", nil); err != nil { 18 | log.Println("ERRO: http listen and serve: ", err) 19 | } 20 | } 21 | 22 | func callback(container *l.Component) { 23 | container.Add( 24 | hlivekit.OnDiffApply( 25 | func(ctx context.Context, e l.Event) { 26 | container.Add(l.T("p", "Diff Applied")) 27 | container.RemoveEventBinding(e.Binding.ID) 28 | }, 29 | ), 30 | ) 31 | } 32 | 33 | func home() *l.Page { 34 | container := l.C("code") 35 | 36 | btn := l.C("button", "Trigger Click", 37 | l.On("click", func(ctx context.Context, e l.Event) { 38 | container.Add(l.T("p", "Click")) 39 | callback(container) 40 | }), 41 | ) 42 | 43 | page := l.NewPage() 44 | page.DOM().Title().Add("Callback Example") 45 | page.DOM().Head().Add( 46 | l.T("link", l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 47 | 48 | page.DOM().Body().Add( 49 | l.T("header", 50 | l.T("h1", "Callback"), 51 | l.T("p", "Get notified when a change has been applied in the browser"), 52 | ), 53 | l.T("main", 54 | l.T("p", btn), 55 | l.T("h2", "Events"), 56 | l.T("pre", container), 57 | ), 58 | ) 59 | 60 | return page 61 | } 62 | -------------------------------------------------------------------------------- /hlivekit/focus_test.go: -------------------------------------------------------------------------------- 1 | package hlivekit_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | "github.com/SamHennessy/hlive/hlivekit" 9 | "github.com/SamHennessy/hlive/hlivetest" 10 | ) 11 | 12 | func TestFocus(t *testing.T) { 13 | t.Parallel() 14 | 15 | // A button, an input, on button click give input focus 16 | pageFn := func() *l.Page { 17 | page := l.NewPage() 18 | 19 | input := l.T("input", l.Attrs{"id": "in_f"}) 20 | 21 | page.DOM().Body().Add( 22 | input, 23 | l.C("button", l.Attrs{"id": "btn_f"}, l.On("click", func(ctx context.Context, e l.Event) { 24 | input.Add(hlivekit.Focus()) 25 | })), 26 | ) 27 | 28 | return page 29 | } 30 | 31 | h := setup(t, pageFn) 32 | defer h.teardown() 33 | 34 | response, err := h.pwpage.EvalOnSelector("#in_f", "(el) => el === document.activeElement") 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | hasFocus, ok := response.(bool) 40 | if !ok { 41 | t.Fatal("focus eval response not a bool") 42 | } 43 | 44 | if hasFocus { 45 | t.Fatal("input already has focus") 46 | } 47 | 48 | done := hlivetest.AckWatcher(t, h.pwpage, "#btn_f") 49 | 50 | if err := h.pwpage.Click("#btn_f"); err != nil { 51 | t.Fatal(err) 52 | } 53 | 54 | <-done 55 | 56 | response, err = h.pwpage.EvalOnSelector("#in_f", "(el) => el === document.activeElement") 57 | if err != nil { 58 | t.Fatal(err) 59 | } 60 | 61 | hasFocus, ok = response.(bool) 62 | if !ok { 63 | t.Fatal("focus eval response not a bool") 64 | } 65 | 66 | if !hasFocus { 67 | t.Fatal("input doesn't have focus") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /hlivekit/diffapply.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | _ "embed" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | ) 8 | 9 | //go:embed diffapply.js 10 | var DiffApplyScript []byte 11 | 12 | // DiffApply is a special event that will trigger when a diff is applied. 13 | // This means that it will trigger itself when first added. This will allow you to know when a change in the tree has 14 | // made it to the browser. You can then, if you wish, immediately remove it from the tree to prevent more triggers. 15 | // You can also add it as a OnOnce and it wil remove itself. 16 | 17 | func OnDiffApply(handler l.EventHandler) *l.ElementGroup { 18 | eb := l.On(DiffApplyEvent, handler) 19 | attr := &DiffApplyAttribute{ 20 | Attribute: l.NewAttribute(DiffApplyAttributeName, eb.ID), 21 | } 22 | 23 | return l.E(eb, attr) 24 | } 25 | 26 | // TODO: how we remove the attribute once done? 27 | func OnDiffApplyOnce(handler l.EventHandler) *l.ElementGroup { 28 | eb := l.OnOnce(DiffApplyEvent, handler) 29 | attr := &DiffApplyAttribute{ 30 | Attribute: l.NewAttribute(DiffApplyAttributeName, eb.ID), 31 | } 32 | 33 | return l.E(eb, attr) 34 | } 35 | 36 | const ( 37 | DiffApplyEvent = "diffapply" 38 | DiffApplyAttributeName = "data-hlive-on-diffapply" 39 | ) 40 | 41 | type DiffApplyAttribute struct { 42 | *l.Attribute 43 | 44 | rendered bool 45 | } 46 | 47 | func (a *DiffApplyAttribute) Initialize(page *l.Page) { 48 | if a.rendered { 49 | return 50 | } 51 | 52 | page.DOM().Head().Add(l.T("script", l.HTML(DiffApplyScript))) 53 | } 54 | 55 | func (a *DiffApplyAttribute) InitializeSSR(page *l.Page) { 56 | a.rendered = true 57 | page.DOM().Head().Add(l.T("script", l.HTML(DiffApplyScript))) 58 | } 59 | -------------------------------------------------------------------------------- /hlivekit/preemptDisableOnClick.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | ) 9 | 10 | const ( 11 | PreemptDisableAttributeName = "data-hlive-pre-disable" 12 | ) 13 | 14 | //go:embed preemptDisableOnClick.js 15 | var PreemptDisableOnClickJavaScript []byte 16 | 17 | // TODO: Once? 18 | func PreemptDisableOn(eb *l.EventBinding) *l.ElementGroup { 19 | sourceAttr := &PreemptDisableAttribute{ 20 | Attribute: l.NewAttribute(PreemptDisableAttributeName, eb.Name), 21 | } 22 | 23 | ogHandler := eb.Handler 24 | 25 | eb.Handler = func(ctx context.Context, e l.Event) { 26 | // Update the Browser DOM with what we've done client first 27 | if sourceAttr.page != nil { 28 | if browserTag := sourceAttr.page.GetBrowserNodeByID(e.Binding.Component.GetID()); browserTag != nil { 29 | browserTag.Add(l.Attrs{"disabled": ""}) 30 | } 31 | } 32 | // Update the Page DOM 33 | if adder, ok := e.Binding.Component.(l.Adder); ok { 34 | adder.Add(l.Attrs{"disabled": ""}) 35 | } else { 36 | l.LoggerDev.Error().Msg("PreemptDisableOn: bound Component must be an Adder") 37 | } 38 | 39 | // Call original handler 40 | if ogHandler != nil { 41 | ogHandler(ctx, e) 42 | } 43 | } 44 | 45 | return l.E(eb, sourceAttr) 46 | } 47 | 48 | type PreemptDisableAttribute struct { 49 | *l.Attribute 50 | 51 | page *l.Page 52 | rendered bool 53 | } 54 | 55 | func (a *PreemptDisableAttribute) Initialize(page *l.Page) { 56 | if a.rendered { 57 | return 58 | } 59 | 60 | a.page = page 61 | page.DOM().Head().Add(l.T("script", l.HTML(PreemptDisableOnClickJavaScript))) 62 | } 63 | 64 | func (a *PreemptDisableAttribute) InitializeSSR(page *l.Page) { 65 | a.rendered = true 66 | a.page = page 67 | page.DOM().Head().Add(l.T("script", l.HTML(PreemptDisableOnClickJavaScript))) 68 | } 69 | -------------------------------------------------------------------------------- /hlivetest/hlivetest.go: -------------------------------------------------------------------------------- 1 | package hlivetest 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/go-test/deep" 7 | "github.com/playwright-community/playwright-go" 8 | ) 9 | 10 | func FatalOnErr(t *testing.T, err error) { 11 | t.Helper() 12 | 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | } 17 | 18 | func Click(t *testing.T, pwpage playwright.Page, selector string) { 19 | t.Helper() 20 | 21 | FatalOnErr(t, pwpage.Click(selector)) 22 | } 23 | 24 | func ClickAndWait(t *testing.T, pwpage playwright.Page, selector string) { 25 | t.Helper() 26 | 27 | done := AckWatcher(t, pwpage, selector) 28 | 29 | Click(t, pwpage, selector) 30 | 31 | <-done 32 | } 33 | 34 | func TextContent(t *testing.T, pwpage playwright.Page, selector string) string { 35 | t.Helper() 36 | 37 | text, err := pwpage.TextContent(selector) 38 | 39 | FatalOnErr(t, err) 40 | 41 | return text 42 | } 43 | 44 | func GetAttribute(t *testing.T, pwpage playwright.Page, selector string, attribute string) string { 45 | t.Helper() 46 | 47 | el, err := pwpage.QuerySelector(selector) 48 | FatalOnErr(t, err) 49 | 50 | val, err := el.GetAttribute(attribute) 51 | FatalOnErr(t, err) 52 | 53 | return val 54 | } 55 | 56 | func GetID(t *testing.T, pwpage playwright.Page, selector string) string { 57 | t.Helper() 58 | 59 | return GetAttribute(t, pwpage, selector, "id") 60 | } 61 | 62 | func Diff(t *testing.T, want any, got any) { 63 | t.Helper() 64 | 65 | if diff := deep.Equal(want, got); diff != nil { 66 | t.Error(diff) 67 | } 68 | } 69 | 70 | func DiffFatal(t *testing.T, want any, got any) { 71 | t.Helper() 72 | 73 | if diff := deep.Equal(want, got); diff != nil { 74 | t.Fatal(diff) 75 | } 76 | } 77 | 78 | func Title(t *testing.T, pwpage playwright.Page) string { 79 | t.Helper() 80 | 81 | title, err := pwpage.Title() 82 | FatalOnErr(t, err) 83 | 84 | return title 85 | } 86 | -------------------------------------------------------------------------------- /_example/preempt/preempt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | l "github.com/SamHennessy/hlive" 10 | "github.com/SamHennessy/hlive/hlivekit" 11 | ) 12 | 13 | func main() { 14 | http.Handle("/", l.NewPageServer(home)) 15 | 16 | log.Println("INFO: listing :3000") 17 | 18 | if err := http.ListenAndServe(":3000", nil); err != nil { 19 | log.Println("ERRO: http listen and serve:", err) 20 | } 21 | } 22 | 23 | func home() *l.Page { 24 | page := l.NewPage() 25 | page.DOM().Title().Add("Preempt Example") 26 | page.DOM().Head().Add(l.T("link", 27 | l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 28 | 29 | countWith := l.Box(0) 30 | 31 | btnWith := l.C("button", countWith) 32 | 33 | btnWith.Add(hlivekit.PreemptDisableOn(l.On("click", 34 | func(_ context.Context, _ l.Event) { 35 | time.Sleep(2 * time.Second) 36 | countWith.Lock(func(v int) int { return v + 1 }) 37 | btnWith.Add(l.AttrsOff{"disabled"}) 38 | }), 39 | )) 40 | 41 | countWithout := l.Box(0) 42 | 43 | btnWithout := l.C("button", countWithout) 44 | 45 | btnWithout.Add(l.On("click", 46 | func(_ context.Context, _ l.Event) { 47 | time.Sleep(2 * time.Second) 48 | countWithout.Lock(func(v int) int { return v + 1 }) 49 | }), 50 | ) 51 | 52 | page.DOM().Body().Add( 53 | l.T("header", 54 | l.T("h1", "Preempt - Client Side First Code"), 55 | l.T("p", "Update the client side DOM before the server side."), 56 | ), 57 | l.T("main", 58 | l.T("p", "The handler will sleep for 2 seconds to simulate a long processing time. "+ 59 | "The first button will be disabled in the browser first to prevent extra clicks. Now click the "+ 60 | "buttons as many times as you can to see the difference"), 61 | "Clicks With: ", 62 | btnWith, 63 | l.T("br"), 64 | "Clicks Without: ", 65 | btnWithout, 66 | ), 67 | ) 68 | 69 | return page 70 | } 71 | -------------------------------------------------------------------------------- /systemtests/addRemove_test.go: -------------------------------------------------------------------------------- 1 | package systemtests_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | "github.com/SamHennessy/hlive/hlivetest" 9 | ) 10 | 11 | func TestAddRemove_AddSibling(t *testing.T) { 12 | t.Parallel() 13 | 14 | pageFn := func() *l.Page { 15 | page := l.NewPage() 16 | 17 | parent := l.T("div", l.Attrs{"id": "parent"}, 18 | l.T("div", l.Attrs{"id": "a"}), 19 | l.T("div", l.Attrs{"id": "b"}), 20 | ) 21 | 22 | page.DOM().Body().Add( 23 | l.C("button", l.Attrs{"id": "btn"}, "Click Me", 24 | l.On("click", func(ctx context.Context, e l.Event) { 25 | parent.Add(l.T("div", l.Attrs{"id": "c"})) 26 | }), 27 | ), 28 | parent, 29 | ) 30 | 31 | return page 32 | } 33 | 34 | h := setup(t, pageFn) 35 | defer h.teardown() 36 | 37 | hlivetest.Diff(t, "b", hlivetest.GetID(t, h.pwpage, "#parent div:last-child")) 38 | 39 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 40 | 41 | hlivetest.Diff(t, "c", hlivetest.GetID(t, h.pwpage, "#parent div:last-child")) 42 | } 43 | 44 | func TestAddRemove_AddMultipleSibling(t *testing.T) { 45 | t.Parallel() 46 | 47 | pageFn := func() *l.Page { 48 | page := l.NewPage() 49 | 50 | parent := l.T("div", l.Attrs{"id": "parent"}, 51 | l.T("div", l.Attrs{"id": "a"}), 52 | l.T("div", l.Attrs{"id": "b"}), 53 | ) 54 | 55 | page.DOM().Body().Add( 56 | l.C("button", l.Attrs{"id": "btn"}, "Click Me", 57 | l.On("click", func(ctx context.Context, e l.Event) { 58 | parent.Add( 59 | l.T("div", l.Attrs{"id": "c"}), 60 | l.T("div", l.Attrs{"id": "d"}), 61 | ) 62 | }), 63 | ), 64 | parent, 65 | ) 66 | 67 | return page 68 | } 69 | 70 | h := setup(t, pageFn) 71 | defer h.teardown() 72 | 73 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 74 | 75 | hlivetest.Diff(t, "c", hlivetest.GetID(t, h.pwpage, "#parent div:nth-child(3)")) 76 | hlivetest.Diff(t, "d", hlivetest.GetID(t, h.pwpage, "#parent div:nth-child(4)")) 77 | } 78 | -------------------------------------------------------------------------------- /systemtests/events_test.go: -------------------------------------------------------------------------------- 1 | package systemtests_test 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "testing" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | "github.com/SamHennessy/hlive/hlivetest" 10 | ) 11 | 12 | func TestEvents_Propagation(t *testing.T) { 13 | t.Parallel() 14 | 15 | var mu sync.Mutex 16 | var btnInner, btnOuter bool 17 | 18 | pageFn := func() *l.Page { 19 | page := l.NewPage() 20 | 21 | page.DOM().Body().Add( 22 | l.C("div", 23 | l.On("click", func(ctx context.Context, e l.Event) { 24 | mu.Lock() 25 | btnOuter = true 26 | mu.Unlock() 27 | }), 28 | 29 | l.C("button", l.Attrs{"id": "btn"}, "Click Me", 30 | l.On("click", func(ctx context.Context, e l.Event) { 31 | mu.Lock() 32 | btnInner = true 33 | mu.Unlock() 34 | }), 35 | ), 36 | ), 37 | ) 38 | 39 | return page 40 | } 41 | 42 | h := setup(t, pageFn) 43 | defer h.teardown() 44 | 45 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 46 | 47 | mu.Lock() 48 | defer mu.Unlock() 49 | 50 | if !btnInner || !btnOuter { 51 | t.Fail() 52 | } 53 | } 54 | 55 | func TestEvents_StopPropagation(t *testing.T) { 56 | t.Parallel() 57 | 58 | var mu sync.Mutex 59 | 60 | var btnInner, btnOuter bool 61 | 62 | pageFn := func() *l.Page { 63 | page := l.NewPage() 64 | 65 | page.DOM().Body().Add( 66 | l.C("div", 67 | l.On("click", func(ctx context.Context, e l.Event) { 68 | mu.Lock() 69 | btnOuter = true 70 | mu.Unlock() 71 | }), 72 | 73 | l.C("button", l.Attrs{"id": "btn"}, "Click Me", 74 | l.StopPropagation(), 75 | l.On("click", func(ctx context.Context, e l.Event) { 76 | mu.Lock() 77 | btnInner = true 78 | mu.Unlock() 79 | }), 80 | ), 81 | ), 82 | ) 83 | 84 | return page 85 | } 86 | 87 | h := setup(t, pageFn) 88 | defer h.teardown() 89 | 90 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 91 | 92 | mu.Lock() 93 | defer mu.Unlock() 94 | 95 | if !btnInner || btnOuter { 96 | t.Fail() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /_example/clock/clock.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | l "github.com/SamHennessy/hlive" 10 | ) 11 | 12 | func main() { 13 | http.Handle("/", home()) 14 | 15 | log.Println("INFO: listing :3000") 16 | 17 | if err := http.ListenAndServe(":3000", nil); err != nil { 18 | log.Println("ERRO: http listen and serve: ", err) 19 | } 20 | } 21 | 22 | func home() *l.PageServer { 23 | f := func() *l.Page { 24 | page := l.NewPage() 25 | page.DOM().Title().Add("Clock Example") 26 | page.DOM().Head().Add( 27 | l.T("link", l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 28 | 29 | page.DOM().Body().Add( 30 | l.T("header", 31 | l.T("h1", "Clock"), 32 | l.T("p", "The time updates are being push from the server every 10ms"), 33 | ), 34 | l.T("main", 35 | l.T("pre", newClock()), 36 | ), 37 | ) 38 | 39 | return page 40 | } 41 | 42 | ps := l.NewPageServer(f) 43 | // Kill the page session 1 second after the tab is closed 44 | ps.Sessions.DisconnectTimeout = time.Second 45 | 46 | return ps 47 | } 48 | 49 | func newClock() *clock { 50 | t := l.NewLockBox("") 51 | 52 | return &clock{ 53 | Component: l.C("code", "Server: ", t), 54 | timeStr: t, 55 | } 56 | } 57 | 58 | type clock struct { 59 | *l.Component 60 | 61 | timeStr *l.LockBox[string] 62 | tick *time.Ticker 63 | } 64 | 65 | func (c *clock) Mount(ctx context.Context) { 66 | log.Println("DEBU: start tick") 67 | 68 | c.tick = time.NewTicker(10 * time.Millisecond) 69 | 70 | go func() { 71 | for { 72 | select { 73 | case <-ctx.Done(): 74 | log.Println("DEBU: tick loop stop: ctx") 75 | 76 | return 77 | case t := <-c.tick.C: 78 | c.timeStr.Set(t.String()) 79 | 80 | l.RenderComponent(ctx, c) 81 | } 82 | } 83 | }() 84 | } 85 | 86 | // Unmount 87 | // Will be called after the page session is deleted 88 | func (c *clock) Unmount(_ context.Context) { 89 | log.Println("DEBU: stop tick") 90 | 91 | if c.tick != nil { 92 | c.tick.Stop() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /systemtests/head_test.go: -------------------------------------------------------------------------------- 1 | package systemtests_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | "github.com/SamHennessy/hlive/hlivetest" 9 | ) 10 | 11 | func TestHead_TitleStatic(t *testing.T) { 12 | t.Parallel() 13 | 14 | pageFn := func() *l.Page { 15 | page := l.NewPage() 16 | 17 | page.DOM().Title().Add("value 1") 18 | 19 | return page 20 | } 21 | 22 | h := setup(t, pageFn) 23 | defer h.teardown() 24 | 25 | hlivetest.Diff(t, "value 1", hlivetest.Title(t, h.pwpage)) 26 | } 27 | 28 | func TestHead_TitleDynamic(t *testing.T) { 29 | t.Parallel() 30 | 31 | pageFn := func() *l.Page { 32 | title := l.Box("value 1") 33 | 34 | page := l.NewPage() 35 | 36 | page.DOM().Title().Add(title) 37 | 38 | page.DOM().Body().Add( 39 | l.C("button", 40 | l.Attrs{"id": "btn"}, 41 | l.On("click", func(ctx context.Context, e l.Event) { 42 | title.Set("value 2") 43 | }), 44 | "Click Me", 45 | ), 46 | ) 47 | 48 | return page 49 | } 50 | 51 | h := setup(t, pageFn) 52 | defer h.teardown() 53 | 54 | hlivetest.Diff(t, "value 1", hlivetest.Title(t, h.pwpage)) 55 | 56 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 57 | 58 | hlivetest.Diff(t, "value 2", hlivetest.Title(t, h.pwpage)) 59 | } 60 | 61 | func TestHead_ScriptTag(t *testing.T) { 62 | t.Parallel() 63 | 64 | pageFn := func() *l.Page { 65 | page := l.NewPage() 66 | 67 | page.DOM().Body().Add( 68 | l.C("button", l.Attrs{"id": "btn"}, "Click Me", 69 | l.On("click", func(ctx context.Context, e l.Event) { 70 | page.DOM().Head().Add(l.T("script", l.HTML(`document.getElementById("content").innerText = "value 2"`))) 71 | }), 72 | ), 73 | l.T("div", l.Attrs{"id": "content"}, "value 1"), 74 | ) 75 | 76 | return page 77 | } 78 | 79 | h := setup(t, pageFn) 80 | defer h.teardown() 81 | 82 | hlivetest.Diff(t, "value 1", hlivetest.TextContent(t, h.pwpage, "#content")) 83 | 84 | hlivetest.ClickAndWait(t, h.pwpage, "#btn") 85 | 86 | hlivetest.Diff(t, "value 2", hlivetest.TextContent(t, h.pwpage, "#content")) 87 | } 88 | -------------------------------------------------------------------------------- /_example/local_render/local_render.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | ) 10 | 11 | func main() { 12 | http.Handle("/", l.NewPageServer(home)) 13 | 14 | log.Println("INFO: listing :3000") 15 | 16 | if err := http.ListenAndServe(":3000", nil); err != nil { 17 | log.Println("ERRO: http listen and serve: ", err) 18 | } 19 | } 20 | 21 | func home() *l.Page { 22 | count := l.Box(0) 23 | 24 | page := l.NewPage() 25 | page.DOM().Title().Add("Local GetNodes Example") 26 | page.DOM().Head().Add(l.T("link", 27 | l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 28 | 29 | page.DOM().Body().Add( 30 | l.T("header", 31 | l.T("h1", "Local Render"), 32 | l.T("p", "By default, the whole page if checked for difference after an event. "+ 33 | "You can override that behaviour and chose to only render a component and it's children."), 34 | ), 35 | l.T("main", 36 | l.T("h2", "Global Render"), 37 | l.T("h4", "Everything will update"), 38 | newCountBtn(count), 39 | l.Group(" The count is: ", l.T("em", count), " clicks"), 40 | l.T("h2", "Local Render"), 41 | l.T("h4", "Only the button will update"), 42 | newCountBtnLocal(count), 43 | l.Group(" The count is: ", l.T("em", count), " clicks"), 44 | ), 45 | ) 46 | 47 | return page 48 | } 49 | 50 | func newCountBtn(count *l.NodeBox[int]) *l.Component { 51 | c := l.C("button", count) 52 | 53 | c.Add(l.On("click", func(ctx context.Context, e l.Event) { 54 | count.Lock(func(v int) int { return v + 1 }) 55 | })) 56 | 57 | return c 58 | } 59 | 60 | func newCountBtnLocal(count *l.NodeBox[int]) *l.Component { 61 | c := l.C("button", count) 62 | 63 | // Don't render this component when an event binding is triggered 64 | c.AutoRender = false 65 | 66 | c.Add(l.On("click", func(ctx context.Context, e l.Event) { 67 | count.Lock(func(v int) int { return v + 1 }) 68 | 69 | // Will render the passed component and it's subtree 70 | l.RenderComponent(ctx, c) 71 | })) 72 | 73 | return c 74 | } 75 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/sha256" 7 | "fmt" 8 | "io" 9 | 10 | "github.com/rs/zerolog" 11 | "github.com/vmihailenco/msgpack/v5" 12 | ) 13 | 14 | const ( 15 | PageHashAttr = "data-hlive-hash" 16 | PageHashAttrTmpl = "{data-hlive-hash}" 17 | ) 18 | 19 | const ( 20 | msgpackExtHTML int8 = iota 21 | msgpackExtTag 22 | msgpackExtAttr 23 | msgpackExtNodeGroup 24 | ) 25 | 26 | func init() { 27 | msgpack.RegisterExt(msgpackExtHTML, (*HTML)(nil)) 28 | msgpack.RegisterExt(msgpackExtTag, (*Tag)(nil)) 29 | msgpack.RegisterExt(msgpackExtAttr, (*Attribute)(nil)) 30 | msgpack.RegisterExt(msgpackExtNodeGroup, (*NodeGroup)(nil)) 31 | } 32 | 33 | // Cache allow cache adapters to be used in HLive 34 | type Cache interface { 35 | Get(key any) (value any, hit bool) 36 | Set(key any, value any) 37 | } 38 | 39 | // PipelineProcessorRenderHashAndCache that will cache the returned tree to support SSR 40 | func PipelineProcessorRenderHashAndCache(logger zerolog.Logger, renderer *Renderer, cache Cache) *PipelineProcessor { 41 | pp := NewPipelineProcessor(PipelineProcessorKeyRenderer) 42 | 43 | pp.AfterWalk = func(ctx context.Context, w io.Writer, node *NodeGroup) (*NodeGroup, error) { 44 | byteBuf := bytes.NewBuffer(nil) 45 | hasher := sha256.New() 46 | multiW := io.MultiWriter(byteBuf, hasher) 47 | 48 | if err := renderer.HTML(multiW, node); err != nil { 49 | return node, fmt.Errorf("renderer.HTML: %w", err) 50 | } 51 | 52 | doc := byteBuf.Bytes() 53 | hhash := fmt.Sprintf("%x", hasher.Sum(nil)) 54 | // Add hhash to the output 55 | doc = bytes.Replace(doc, []byte(PageHashAttrTmpl), []byte(hhash), 1) 56 | 57 | if nodeBytes, err := msgpack.Marshal(node); err != nil { 58 | logger.Err(err).Msg("PipelineProcessorRenderHashAndCache: msgpack.Marshal") 59 | } else { 60 | cache.Set(hhash, nodeBytes) 61 | logger.Debug().Str("hhash", hhash).Int("size", len(nodeBytes)/1024).Msg("cache set") 62 | } 63 | 64 | if _, err := w.Write(doc); err != nil { 65 | return node, fmt.Errorf("write doc: %w", err) 66 | } 67 | 68 | return node, nil 69 | } 70 | 71 | return pp 72 | } 73 | -------------------------------------------------------------------------------- /hlivetest/ack.go: -------------------------------------------------------------------------------- 1 | package hlivetest 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "fmt" 7 | "testing" 8 | 9 | l "github.com/SamHennessy/hlive" 10 | "github.com/playwright-community/playwright-go" 11 | "github.com/teris-io/shortid" 12 | ) 13 | 14 | //go:embed ack.js 15 | var ackJavaScript []byte 16 | 17 | const ( 18 | ackAttrName = "data-hlive-test-ack" 19 | ackIDAttrName = "data-hlive-test-ack-id" 20 | ackExtraKey = "test-ack-id" 21 | ackCtxKey = "browser_testing.ack" 22 | ) 23 | 24 | func Ack() l.Attributer { 25 | return &ack{ 26 | l.NewAttribute(ackAttrName, ""), 27 | } 28 | } 29 | 30 | type ack struct { 31 | *l.Attribute 32 | } 33 | 34 | func (a *ack) Initialize(page *l.Page) { 35 | page.HookBeforeEventAdd(ackBeforeEvent) 36 | page.HookAfterRenderAdd(ackAfterRender) 37 | page.DOM().Head().Add(l.T("script", l.HTML(ackJavaScript))) 38 | } 39 | 40 | // Look in the extra data for ack id and add the value to the context if found 41 | func ackBeforeEvent(ctx context.Context, e l.Event) (context.Context, l.Event) { 42 | if e.Extra[ackExtraKey] != "" { 43 | return context.WithValue(ctx, ackCtxKey, e.Extra[ackExtraKey]), e 44 | } 45 | 46 | return ctx, e 47 | } 48 | 49 | // If ack id in context then send a message 50 | func ackAfterRender(ctx context.Context, diffs []l.Diff, send chan<- l.MessageWS) { 51 | ackID, ok := ctx.Value(ackCtxKey).(string) 52 | if !ok || ackID == "" { 53 | return 54 | } 55 | 56 | send <- l.MessageWS{Message: []byte("ack|" + ackID + "\n")} 57 | } 58 | 59 | // TODO: detect timeout and errors 60 | func AckWatcher(t *testing.T, page playwright.Page, selector string) <-chan error { 61 | t.Helper() 62 | 63 | id := shortid.MustGenerate() 64 | 65 | _, err := page.EvalOnSelector(selector, "node => node.setAttribute(\"data-hlive-test-ack-id\", \""+id+"\")") 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | 70 | done := make(chan error) 71 | 72 | go func() { 73 | if _, err := page.WaitForFunction("hliveTestAck.received[\""+id+"\"] === true", nil); err != nil { 74 | done <- fmt.Errorf("wait for function: %w", err) 75 | 76 | return 77 | } 78 | 79 | done <- nil 80 | }() 81 | 82 | return done 83 | } 84 | -------------------------------------------------------------------------------- /hlivekit/componentListSimple.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | ) 9 | 10 | // ComponentListSimple is a version of ComponentList that doesn't have the Teardown logic 11 | type ComponentListSimple struct { 12 | *l.ComponentMountable 13 | 14 | items []l.UniqueTagger 15 | mu sync.RWMutex 16 | } 17 | 18 | // NewComponentListSimple creates a ComponentListSimple value 19 | func NewComponentListSimple(name string, elements ...any) *ComponentListSimple { 20 | list := &ComponentListSimple{ 21 | ComponentMountable: l.CM(name), 22 | } 23 | 24 | list.Add(elements...) 25 | 26 | return list 27 | } 28 | 29 | // GetNodes returns the list of items. 30 | func (list *ComponentListSimple) GetNodes() *l.NodeGroup { 31 | list.mu.RLock() 32 | defer list.mu.RUnlock() 33 | 34 | return l.G(list.items) 35 | } 36 | 37 | // Add an element to this ComponentListSimple. 38 | // 39 | // You can add Groups, UniqueTagger, EventBinding, or None Node Elements 40 | func (list *ComponentListSimple) Add(elements ...any) { 41 | for i := 0; i < len(elements); i++ { 42 | switch v := elements[i].(type) { 43 | case *l.NodeGroup: 44 | list.Add(v.Get()...) 45 | case l.UniqueTagger: 46 | list.items = append(list.items, v) 47 | default: 48 | if l.IsNonNodeElement(v) { 49 | list.Component.Add(v) 50 | } else { 51 | l.LoggerDev.Warn().Str("callers", l.CallerStackStr()). 52 | Str("element", fmt.Sprintf("%#v", v)). 53 | Msg("invalid element type") 54 | } 55 | } 56 | } 57 | } 58 | 59 | func (list *ComponentListSimple) AddItems(items ...l.UniqueTagger) { 60 | list.mu.Lock() 61 | list.items = append(list.items, items...) 62 | list.mu.Unlock() 63 | } 64 | 65 | func (list *ComponentListSimple) RemoveItems(items ...l.UniqueTagger) { 66 | list.mu.Lock() 67 | defer list.mu.Unlock() 68 | 69 | var newList []l.UniqueTagger 70 | 71 | for i := 0; i < len(list.items); i++ { 72 | hit := false 73 | 74 | for j := 0; j < len(items); j++ { 75 | item := items[j] 76 | 77 | if item.GetID() == list.items[i].GetID() { 78 | hit = true 79 | 80 | break 81 | } 82 | } 83 | 84 | if !hit { 85 | newList = append(newList, list.items[i]) 86 | } 87 | } 88 | 89 | list.items = newList 90 | } 91 | 92 | func (list *ComponentListSimple) RemoveAllItems() { 93 | list.mu.Lock() 94 | list.items = nil 95 | list.mu.Unlock() 96 | } 97 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | ) 7 | 8 | type Event struct { 9 | // The binding that was listening for this event 10 | Binding *EventBinding 11 | // If an input has a value set by the browsers on page load, different to the inputs value attribute this type of 12 | // event is sent. This typically happens on page reload after data has been inputted to a field. 13 | IsInitial bool 14 | // The value of the field, if relevant 15 | Value string 16 | // Used when an event source could have multiple values 17 | Values []string 18 | // Selected is true, for the element interacted with, if a radio or checkbox is checked or a select option is selected. 19 | // Most relevant for checkbox as it always has a value, this lets you know if they are currently checked or not. 20 | Selected bool 21 | // TODO: move to nillable value 22 | // Key related values are only used on keyboard related events 23 | Key string 24 | CharCode int 25 | KeyCode int 26 | ShiftKey bool 27 | AltKey bool 28 | CtrlKey bool 29 | // Used for file inputs and uploads 30 | File *File 31 | // Extra, for non-browser related data, for use by plugins 32 | Extra map[string]string 33 | } 34 | 35 | type File struct { 36 | // File name 37 | Name string 38 | // Size of the file in bytes 39 | Size int 40 | // Mime type 41 | Type string 42 | // The file contents 43 | Data []byte 44 | // Which file is this in the total file count, 0 index 45 | Index int 46 | // How many files are being uploaded in total 47 | Total int 48 | } 49 | 50 | type EventHandler func(ctx context.Context, e Event) 51 | 52 | func NewEventBinding() *EventBinding { 53 | return &EventBinding{} 54 | } 55 | 56 | type EventBinding struct { 57 | // Unique ID for this binding 58 | ID string 59 | // Function to call when binding is triggered 60 | Handler EventHandler 61 | // Component we are bound to 62 | Component Componenter 63 | // Call this binding once then discard it 64 | Once bool 65 | // Name of the JavaScript event that will trigger this binding 66 | Name string 67 | } 68 | 69 | func On(name string, handler EventHandler) *EventBinding { 70 | binding := NewEventBinding() 71 | binding.Handler = handler 72 | binding.Name = strings.ToLower(name) 73 | 74 | return binding 75 | } 76 | 77 | func OnOnce(name string, handler EventHandler) *EventBinding { 78 | binding := On(name, handler) 79 | binding.Once = true 80 | 81 | return binding 82 | } 83 | -------------------------------------------------------------------------------- /_example/url_params/url_params.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | "github.com/SamHennessy/hlive/hlivekit" 10 | ) 11 | 12 | func main() { 13 | http.Handle("/", 14 | urlParamsMiddleware( 15 | l.NewPageServer(home).ServeHTTP, 16 | ), 17 | ) 18 | 19 | log.Println("INFO: listing :3000") 20 | 21 | if err := http.ListenAndServe(":3000", nil); err != nil { 22 | log.Println("ERRO: http listen and serve: ", err) 23 | } 24 | } 25 | 26 | func home() *l.Page { 27 | page := l.NewPage() 28 | page.DOM().Title().Add("URL Params Example") 29 | page.DOM().Head().Add( 30 | l.T("link", l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 31 | 32 | page.DOM().Body().Add( 33 | l.T("header", 34 | l.T("h1", "URL Get Parameter Read Example"), 35 | l.T("p", "This example reads the parameters from the URL and prints them in a table."), 36 | ), 37 | l.T("main", 38 | l.T("p", "Add your own query parameters to the url and load the page again."), 39 | l.T("h2", "Values"), 40 | ), 41 | ) 42 | 43 | cl := hlivekit.List("tbody") 44 | 45 | cm := l.CM("table", 46 | l.T("thead", 47 | l.T("tr", 48 | l.T("th", "Key"), 49 | l.T("th", "Value"), 50 | ), 51 | ), 52 | cl, 53 | ) 54 | 55 | cm.SetMount(func(ctx context.Context) { 56 | for key, value := range urlParamsFromCtx(ctx) { 57 | cl.AddItem(l.CM("tr", 58 | l.T("td", key), 59 | l.T("td", value), 60 | )) 61 | } 62 | }) 63 | 64 | page.DOM().Body().Add( 65 | cm, 66 | l.T("p", "You will see the extra 'hlive' parameter that HLive adds on when establishing a WebSocket connection."), 67 | ) 68 | 69 | return page 70 | } 71 | 72 | type ctxKey string 73 | 74 | const ctxURLParams ctxKey = "url" 75 | 76 | func urlParamsMiddleware(h http.HandlerFunc) http.HandlerFunc { 77 | return func(w http.ResponseWriter, r *http.Request) { 78 | // Only on WebSocket requests 79 | if r.URL.Query().Get("hlive") != "" { 80 | params := map[string]string{} 81 | for key := range r.URL.Query() { 82 | params[key] = r.URL.Query().Get(key) 83 | } 84 | 85 | r = r.WithContext(context.WithValue(r.Context(), ctxURLParams, params)) 86 | } 87 | 88 | h(w, r) 89 | } 90 | } 91 | 92 | func urlParamsFromCtx(ctx context.Context) map[string]string { 93 | params, ok := ctx.Value(ctxURLParams).(map[string]string) 94 | if ok && params != nil { 95 | return params 96 | } 97 | 98 | return map[string]string{} 99 | } 100 | -------------------------------------------------------------------------------- /component_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import ( 4 | "testing" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | "github.com/go-test/deep" 8 | ) 9 | 10 | func TestComponent_GetID(t *testing.T) { 11 | t.Parallel() 12 | 13 | c := l.C("div") 14 | c.SetID("1") 15 | b := l.C("div") 16 | b.SetID("2") 17 | 18 | if c.GetID() == "" || b.GetID() == "" { 19 | t.Error("id is an empty string") 20 | } 21 | 22 | if c.GetID() == b.GetID() { 23 | t.Error("id not unique") 24 | } 25 | 26 | if diff := deep.Equal(c.GetID(), c.GetAttributeValue(l.AttrID)); diff != nil { 27 | t.Error(diff) 28 | } 29 | } 30 | 31 | func TestComponent_IsAutoRender(t *testing.T) { 32 | t.Parallel() 33 | 34 | c := l.C("div") 35 | 36 | if !c.IsAutoRender() { 37 | t.Error("auto render not true by default") 38 | } 39 | 40 | c.AutoRender = false 41 | 42 | if c.IsAutoRender() { 43 | t.Error("not able to set auto render") 44 | } 45 | } 46 | 47 | func TestComponent_AddAttribute(t *testing.T) { 48 | t.Parallel() 49 | 50 | c := l.C("div") 51 | c.SetID("1") 52 | 53 | eb1 := l.On("input", nil) 54 | eb2 := l.On("click", nil) 55 | 56 | if c.GetAttributeValue(l.AttrOn) != "" { 57 | t.Errorf("unexpected value for %s = %s", l.AttrOn, c.GetAttributeValue(l.AttrOn)) 58 | } 59 | 60 | c.Add(eb1) 61 | 62 | expected := eb1.ID + "|" + eb1.Name 63 | if diff := deep.Equal(expected, c.GetAttributeValue(l.AttrOn)); diff != nil { 64 | t.Error(diff) 65 | } 66 | 67 | c.Add(eb2) 68 | 69 | expected = eb1.ID + "|" + eb1.Name + "," + eb2.ID + "|" + eb2.Name 70 | if diff := deep.Equal(expected, c.GetAttributeValue(l.AttrOn)); diff != nil { 71 | t.Error(diff) 72 | } 73 | } 74 | 75 | func TestComponent_AddGetEventBinding(t *testing.T) { 76 | t.Parallel() 77 | 78 | eb1 := l.On("input", nil) 79 | 80 | c := l.C("div", eb1) 81 | 82 | if c.GetEventBinding(eb1.ID) == nil { 83 | t.Error("event binding not found") 84 | } 85 | } 86 | 87 | func TestComponent_AddRemoveEventBinding(t *testing.T) { 88 | t.Parallel() 89 | 90 | eb1 := l.On("input", nil) 91 | eb2 := l.On("click", nil) 92 | 93 | c := l.C("div", eb1, eb2) 94 | c.SetID("1") 95 | 96 | c.RemoveEventBinding(eb1.ID) 97 | 98 | if c.GetEventBinding(eb1.ID) != nil { 99 | t.Error("event binding not removed") 100 | } 101 | 102 | if c.GetEventBinding(eb2.ID) == nil { 103 | t.Error("event binding not found") 104 | } 105 | } 106 | 107 | func TestComponent_Wrap(t *testing.T) { 108 | t.Parallel() 109 | 110 | tag := l.T("div") 111 | c := l.W(tag) 112 | 113 | if c.Tag != tag { 114 | t.Error("tag not wrapped") 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /hlivekit/componentList.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | ) 9 | 10 | var ErrInvalidListAdd = errors.New("value is not valid for a list") 11 | 12 | // ComponentList is a way to manage a dynamic collection of Teardowner Node values. For example, the rows of a table. 13 | // 14 | // As the Node values in ComponentList are often added and removed there lies the possibility of memory leaks. To 15 | // prevent this the items in the list must be Teardowner values. The list will call Teardown on each item as long as 16 | // they are removed using its RemoveItem and RemoveAllItems functions. 17 | // 18 | // See NewComponentMountable, CM, WrapMountable, and WM for help with creating Teardowner values. 19 | type ComponentList struct { 20 | *ComponentListSimple 21 | } 22 | 23 | // List is a shortcut for NewComponentList. 24 | func List(name string, elements ...any) *ComponentList { 25 | return NewComponentList(name, elements...) 26 | } 27 | 28 | // NewComponentList returns a value of ComponentList 29 | func NewComponentList(name string, elements ...any) *ComponentList { 30 | return &ComponentList{ 31 | ComponentListSimple: NewComponentListSimple(name, elements...), 32 | } 33 | } 34 | 35 | // Add an element to this Component. 36 | // 37 | // You can add Groups, Teardowner, EventBinding, or None Node Elements 38 | func (list *ComponentList) Add(elements ...any) { 39 | for i := 0; i < len(elements); i++ { 40 | switch v := elements[i].(type) { 41 | case *l.NodeGroup: 42 | g := v.Get() 43 | for j := 0; j < len(g); j++ { 44 | list.Add(g[j]) 45 | } 46 | case l.Teardowner: 47 | list.AddItems(v) 48 | case *l.EventBinding: 49 | list.Component.Add(v) 50 | default: 51 | if l.IsNonNodeElement(v) { 52 | list.Tag.Add(v) 53 | } else { 54 | l.LoggerDev.Error(). 55 | Str("callers", l.CallerStackStr()). 56 | Str("element", fmt.Sprintf("%#v", v)). 57 | Msg("invalid element") 58 | } 59 | } 60 | } 61 | } 62 | 63 | // AddItem allows you to add a node to the list 64 | // 65 | // Add nodes are often added and removed nodes needed to be a Teardowner. 66 | // See NewComponentMountable, CM, WrapMountable, and WM for help with creating Teardowner values. 67 | func (list *ComponentList) AddItem(items ...l.Teardowner) { 68 | for i := 0; i < len(items); i++ { 69 | list.ComponentListSimple.AddItems(items[i]) 70 | } 71 | } 72 | 73 | // RemoveItems will remove a Teardowner can call its Teardown function. 74 | func (list *ComponentList) RemoveItems(items ...l.Teardowner) { 75 | for i := 0; i < len(items); i++ { 76 | list.ComponentListSimple.RemoveItems(items[i]) 77 | items[i].Teardown() 78 | } 79 | } 80 | 81 | // RemoveAllItems empties the list of items and calls Teardown on each of them. 82 | func (list *ComponentList) RemoveAllItems() { 83 | for i := 0; i < len(list.ComponentListSimple.items); i++ { 84 | if td, ok := list.ComponentListSimple.items[i].(l.Teardowner); ok { 85 | td.Teardown() 86 | } 87 | } 88 | 89 | list.ComponentListSimple.RemoveAllItems() 90 | } 91 | -------------------------------------------------------------------------------- /_example/file_upload/file_upload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | 10 | l "github.com/SamHennessy/hlive" 11 | ) 12 | 13 | func main() { 14 | http.Handle("/", home()) 15 | 16 | log.Println("INFO: listing :3000") 17 | 18 | if err := http.ListenAndServe(":3000", nil); err != nil { 19 | log.Println("ERRO: http listen and serve: ", err) 20 | } 21 | } 22 | 23 | func home() *l.PageServer { 24 | f := func() *l.Page { 25 | var ( 26 | file = l.NewLockBox(l.File{}) 27 | fileName = l.Box("") 28 | fileType = l.Box("") 29 | fileSize = l.Box(0) 30 | tableDisplay = l.NewLockBox("none") 31 | fileDisplay = l.NewLockBox("none") 32 | ) 33 | 34 | page := l.NewPage() 35 | page.DOM().Title().Add("File Upload Example") 36 | page.DOM().Head().Add(l.T("link", 37 | l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 38 | 39 | iframe := l.T("iframe", l.Style{"width": "100%", "height": "80vh"}) 40 | 41 | fileInput := l.C("input", l.Attrs{"type": "file"}) 42 | fileInput.Add( 43 | l.On("upload", func(ctx context.Context, e l.Event) { 44 | file.Lock(func(v l.File) l.File { 45 | // This is a bad idea, using as an easy demo. 46 | // The file is kept in server-side browser DOM using memory. 47 | src := fmt.Sprintf("data:%s;base64,%s", e.File.Type, 48 | base64.StdEncoding.EncodeToString(e.File.Data)) 49 | iframe.Add(l.Attrs{"src": src}) 50 | fileDisplay.Set("box") 51 | fileInput.RemoveAttributes(l.AttrUpload) 52 | 53 | return *e.File 54 | }) 55 | }), 56 | l.On("change", func(ctx context.Context, e l.Event) { 57 | if e.File != nil { 58 | file.Lock(func(v l.File) l.File { 59 | v = *e.File 60 | fileName.Set(v.Name) 61 | fileType.Set(v.Type) 62 | fileSize.Set(v.Size) 63 | 64 | tableDisplay.Set("box") 65 | 66 | return v 67 | }) 68 | } 69 | }), 70 | ) 71 | 72 | uploadBtn := l.C("button", "Upload", 73 | l.On("click", func(ctx context.Context, e l.Event) { 74 | fileInput.Add(l.Attrs{l.AttrUpload: ""}) 75 | }), 76 | ) 77 | 78 | page.DOM().Body().Add( 79 | l.T("header", 80 | l.T("h1", "Upload"), 81 | l.T("p", "Example of using the file upload features."), 82 | ), 83 | l.T("main", 84 | l.T("p", 85 | fileInput, 86 | uploadBtn, 87 | ), 88 | l.T("div", l.StyleLockBox{"display": tableDisplay}, 89 | l.T("table", 90 | l.T("tbody", 91 | l.T("tr", 92 | l.T("td", "Name"), l.T("td", fileName), 93 | ), 94 | l.T("tr", 95 | l.T("td", "Type"), l.T("td", fileType), 96 | ), 97 | l.T("tr", 98 | l.T("td", "Size"), l.T("td", l.T("i", fileSize), " bytes"), 99 | ), 100 | ), 101 | ), 102 | ), 103 | l.T("div", l.StyleLockBox{"display": fileDisplay}, 104 | l.T("h3", "Uploaded File"), 105 | l.T("hr"), 106 | iframe, 107 | ), 108 | ), 109 | ) 110 | 111 | return page 112 | } 113 | 114 | return l.NewPageServer(f) 115 | } 116 | -------------------------------------------------------------------------------- /componentMountable.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Mounter wants to be notified after it's mounted. 8 | type Mounter interface { 9 | UniqueTagger 10 | // Mount is called after a component is mounted 11 | Mount(ctx context.Context) 12 | } 13 | 14 | // Unmounter wants to be notified before it's unmounted. 15 | type Unmounter interface { 16 | UniqueTagger 17 | // Unmount is called before a component is unmounted 18 | Unmount(ctx context.Context) 19 | } 20 | 21 | // Teardowner wants to have manual control when it needs to be removed from a Page. 22 | // If you have a Mounter or Unmounter that will be permanently removed from a Page they must call the passed 23 | // function to clean up their references. 24 | type Teardowner interface { 25 | UniqueTagger 26 | // AddTeardown adds a teardown function 27 | AddTeardown(teardown func()) 28 | // Teardown call the set teardown function passed in SetTeardown 29 | Teardown() 30 | } 31 | 32 | type ComponentMountable struct { 33 | *Component 34 | 35 | mountFunc func(ctx context.Context) 36 | unmountFunc func(ctx context.Context) 37 | teardowns []func() 38 | } 39 | 40 | // CM is a shortcut for NewComponentMountable 41 | func CM(name string, elements ...any) *ComponentMountable { 42 | return NewComponentMountable(name, elements...) 43 | } 44 | 45 | func NewComponentMountable(name string, elements ...any) *ComponentMountable { 46 | return &ComponentMountable{ 47 | Component: NewComponent(name, elements...), 48 | } 49 | } 50 | 51 | func (c *ComponentMountable) Mount(ctx context.Context) { 52 | if c == nil { 53 | return 54 | } 55 | 56 | c.Tag.mu.RLock() 57 | f := c.mountFunc 58 | c.Tag.mu.RUnlock() 59 | 60 | if f != nil { 61 | f(ctx) 62 | } 63 | } 64 | 65 | func (c *ComponentMountable) Unmount(ctx context.Context) { 66 | if c == nil { 67 | return 68 | } 69 | 70 | c.Tag.mu.RLock() 71 | f := c.unmountFunc 72 | c.Tag.mu.RUnlock() 73 | 74 | if c.unmountFunc != nil { 75 | f(ctx) 76 | } 77 | } 78 | 79 | func (c *ComponentMountable) SetMount(mount func(ctx context.Context)) { 80 | c.Tag.mu.Lock() 81 | c.mountFunc = mount 82 | c.Tag.mu.Unlock() 83 | } 84 | 85 | func (c *ComponentMountable) SetUnmount(unmount func(ctx context.Context)) { 86 | c.Tag.mu.Lock() 87 | c.unmountFunc = unmount 88 | c.Tag.mu.Unlock() 89 | } 90 | 91 | func (c *ComponentMountable) AddTeardown(teardown func()) { 92 | c.Tag.mu.Lock() 93 | c.teardowns = append(c.teardowns, teardown) 94 | c.Tag.mu.Unlock() 95 | } 96 | 97 | func (c *ComponentMountable) Teardown() { 98 | c.Tag.mu.RLock() 99 | teardowns := c.teardowns 100 | c.Tag.mu.RUnlock() 101 | 102 | for i := 0; i < len(teardowns); i++ { 103 | teardowns[i]() 104 | } 105 | } 106 | 107 | // WM is a shortcut for WrapMountable. 108 | func WM(tag *Tag, elements ...any) *ComponentMountable { 109 | return WrapMountable(tag, elements...) 110 | } 111 | 112 | // WrapMountable takes a Tag and creates a Component with it. 113 | func WrapMountable(tag *Tag, elements ...any) *ComponentMountable { 114 | return &ComponentMountable{ 115 | Component: Wrap(tag, elements), 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /_example/animation/animation.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | ) 10 | 11 | const pageStyle l.HTML = ` 12 | .box { 13 | overflow: hidden; 14 | padding: 3em; 15 | text-align: center; 16 | border: solid; 17 | } 18 | .text { 19 | display: inline-block; 20 | font-size: 3em; 21 | } 22 | ` 23 | 24 | var animations = []string{ 25 | "animate__hinge", "animate__jackInTheBox", "animate__rollIn", "animate__rollOut", 26 | "animate__bounce", "animate__flash", "animate__pulse", "animate__rubberBand", "animate__shakeX", 27 | "animate__shakeY", "animate__headShake", "animate__swing", "animate__tada", "animate__wobble", 28 | "animate__jello", "animate__heartBeat", "animate__flip", "animate__backInDown", "animate__backOutDown", 29 | } 30 | 31 | func main() { 32 | http.Handle("/", l.NewPageServer(home)) 33 | 34 | log.Println("INFO: listing :3000") 35 | 36 | if err := http.ListenAndServe(":3000", nil); err != nil { 37 | log.Println("ERRO: http listen and serve: ", err) 38 | } 39 | } 40 | 41 | func home() *l.Page { 42 | var ( 43 | index = l.Box(0) 44 | current = l.Box("") 45 | playing = l.NewLockBox(false) 46 | btnLabel = l.Box("Start") 47 | ) 48 | 49 | animationTarget := l.C("div", l.Class("animate__animated text"), "HLive") 50 | 51 | nextAnimation := func() { 52 | index.Lock(func(v int) int { 53 | animationTarget.Add(l.ClassOff(animations[v])) 54 | 55 | v++ 56 | if len(animations) == v { 57 | v = 0 58 | } 59 | 60 | current.Set(animations[v]) 61 | animationTarget.Add(l.Class(animations[v])) 62 | 63 | return v 64 | }) 65 | } 66 | 67 | animationTarget.Add(l.On("animationend", func(ctx context.Context, e l.Event) { 68 | if playing.Get() { 69 | nextAnimation() 70 | } 71 | })) 72 | 73 | animationTarget.Add(l.On("animationcancel", func(ctx context.Context, e l.Event) { 74 | playing.Set(false) 75 | btnLabel.Set("Start") 76 | current.Set("") 77 | })) 78 | 79 | btn := l.C("button", btnLabel, 80 | l.On("click", func(ctx context.Context, e l.Event) { 81 | playing.Lock(func(v bool) bool { 82 | if !v { 83 | nextAnimation() 84 | btnLabel.Set("Stop") 85 | } else { 86 | btnLabel.Set("Start") 87 | current.Set("") 88 | } 89 | 90 | return !v 91 | }) 92 | }), 93 | // You can create multiple event bindings for the same event and component 94 | l.On("click", func(ctx context.Context, e l.Event) { 95 | log.Println("INFO: Button Clicked") 96 | }), 97 | ) 98 | 99 | page := l.NewPage() 100 | page.DOM().Title().Add("Animation Example") 101 | page.DOM().Head().Add( 102 | l.T("link", l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"}), 103 | l.T("link", 104 | l.Attrs{"rel": "stylesheet", "href": "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"}), 105 | l.T("style", pageStyle), 106 | ) 107 | 108 | page.DOM().Body().Add( 109 | l.T("header", 110 | l.T("h1", "CSS Animations"), 111 | l.T("p", "We can wait for the CSS animation to end before starting the next one"), 112 | ), 113 | l.T("main", 114 | l.T("p", btn), 115 | l.T("p", "Current: ", current), 116 | l.T("div", l.Class("box"), animationTarget), 117 | ), 118 | ) 119 | 120 | return page 121 | } 122 | -------------------------------------------------------------------------------- /tag_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import ( 4 | "testing" 5 | 6 | l "github.com/SamHennessy/hlive" 7 | "github.com/go-test/deep" 8 | ) 9 | 10 | func TestTag_T(t *testing.T) { 11 | t.Parallel() 12 | 13 | tag := l.T("div") 14 | 15 | if tag == nil { 16 | t.Fatal("nil returned") 17 | } 18 | 19 | if diff := deep.Equal("div", tag.GetName()); diff != nil { 20 | t.Error(diff) 21 | } 22 | 23 | if diff := deep.Equal(0, len(tag.GetAttributes())); diff != nil { 24 | t.Error(diff) 25 | } 26 | } 27 | 28 | func TestTag_IsVoid(t *testing.T) { 29 | t.Parallel() 30 | 31 | div := l.T("div") 32 | hr := l.T("hr") 33 | 34 | if diff := deep.Equal(false, div.IsVoid()); diff != nil { 35 | t.Error(diff) 36 | } 37 | 38 | if diff := deep.Equal(true, hr.IsVoid()); diff != nil { 39 | t.Error(diff) 40 | } 41 | } 42 | 43 | // TODO: Now that this doesn't panic, update to check for log output 44 | func TestTag_AddNodeTypes(t *testing.T) { 45 | t.Parallel() 46 | 47 | tests := []struct { 48 | name string 49 | arg any 50 | }{ 51 | {"nil", nil}, 52 | {"string", "string"}, 53 | {"HTML", l.HTML("")}, 54 | {"Tagger", &testTagger{}}, 55 | {"UniqueTagger", &testUniqueTagger{}}, 56 | {"Componenter", &testComponenter{}}, 57 | {"[]any", []any{}}, 58 | {"NodeGroup", l.G()}, 59 | {"[]*Tag", []*l.Tag{}}, 60 | {"[]Tagger", []l.Tagger{}}, 61 | {"[]*Component", []*l.Component{}}, 62 | {"[]Componenter", []l.Componenter{}}, 63 | {"[]UniqueTagger", []l.UniqueTagger{}}, 64 | {"float34", float32(1)}, 65 | {"float64", float64(1)}, 66 | {"int", 1}, 67 | {"int8", int8(1)}, 68 | {"int16", int16(1)}, 69 | {"int32", int32(1)}, 70 | {"int64", int64(1)}, 71 | {"uint", uint(1)}, 72 | {"uint8", uint8(1)}, 73 | {"uint16", uint16(1)}, 74 | {"uint32", uint32(1)}, 75 | {"uint64", uint64(1)}, 76 | {"*NodeBox[V]", l.Box("")}, 77 | {"*LockBox[V]", l.NewLockBox("")}, 78 | } 79 | 80 | for _, tt := range tests { 81 | tt := tt 82 | 83 | t.Run(tt.name, func(t *testing.T) { 84 | t.Parallel() 85 | 86 | tag := l.T("div") 87 | tag.Add(tt.arg) 88 | }) 89 | } 90 | } 91 | 92 | func TestTag_AddElementTypes(t *testing.T) { 93 | t.Parallel() 94 | 95 | tests := []struct { 96 | name string 97 | arg any 98 | }{ 99 | {"*Attribute", l.AttrsOff{"value"}}, 100 | {"[]*Attribute", []*l.Attribute{}}, 101 | {"Attrs", l.Attrs{}}, 102 | {"ClassBool", l.ClassBool{}}, 103 | {"Style", l.Style{}}, 104 | } 105 | 106 | for _, tt := range tests { 107 | tt := tt 108 | 109 | t.Run(tt.name, func(t *testing.T) { 110 | t.Parallel() 111 | 112 | tag := l.T("div") 113 | tag.Add(tt.arg) 114 | }) 115 | } 116 | } 117 | 118 | func TestTag_AddNode(t *testing.T) { 119 | t.Parallel() 120 | 121 | parent := l.T("div") 122 | parent.Add(l.T("div")) 123 | 124 | if len(parent.GetNodes().Get()) != 1 { 125 | t.Fatalf("expected 1 child got %v", len(parent.GetNodes().Get())) 126 | } 127 | 128 | parent.Add("foo") 129 | 130 | if len(parent.GetNodes().Get()) != 2 { 131 | t.Fatalf("expected 2 children got %v", len(parent.GetNodes().Get())) 132 | } 133 | } 134 | 135 | func TestTag_AddNodes(t *testing.T) { 136 | t.Parallel() 137 | 138 | parent := l.T("div") 139 | parent.Add(l.T("div"), "foo") 140 | 141 | if len(parent.GetNodes().Get()) != 2 { 142 | t.Fatal("expected 2 children") 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /pageSessionStore.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "runtime" 5 | "sync/atomic" 6 | "time" 7 | 8 | "github.com/cornelk/hashmap" 9 | "github.com/rs/xid" 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | func NewPageSessionStore() *PageSessionStore { 14 | pss := &PageSessionStore{ 15 | DisconnectTimeout: WebSocketDisconnectTimeoutDefault, 16 | SessionLimit: PageSessionLimitDefault, 17 | GarbageCollectionTick: PageSessionGarbageCollectionTick, 18 | Done: make(chan bool), 19 | sessions: hashmap.New[string, *PageSession](), 20 | } 21 | 22 | go pss.GarbageCollection() 23 | 24 | return pss 25 | } 26 | 27 | type PageSessionStore struct { 28 | sessions *hashmap.Map[string, *PageSession] 29 | DisconnectTimeout time.Duration 30 | SessionLimit uint32 31 | sessionCount uint32 32 | GarbageCollectionTick time.Duration 33 | Done chan bool 34 | } 35 | 36 | // New PageSession. 37 | func (pss *PageSessionStore) New() *PageSession { 38 | // Block until we have room for a new session 39 | pss.newWait() 40 | 41 | ps := &PageSession{ 42 | id: xid.New().String(), 43 | logger: zerolog.Nop(), 44 | Send: make(chan MessageWS), 45 | Receive: make(chan MessageWS), 46 | done: make(chan bool), 47 | } 48 | 49 | pss.mapAdd(ps) 50 | 51 | return ps 52 | } 53 | 54 | // TODO: use sync.Cond 55 | func (pss *PageSessionStore) newWait() { 56 | for atomic.LoadUint32(&pss.sessionCount) > pss.SessionLimit { 57 | runtime.Gosched() 58 | } 59 | } 60 | 61 | func (pss *PageSessionStore) Get(id string) *PageSession { 62 | return pss.mapGet(id) 63 | } 64 | 65 | func (pss *PageSessionStore) mapAdd(ps *PageSession) { 66 | pss.sessions.Set(ps.id, ps) 67 | atomic.AddUint32(&pss.sessionCount, 1) 68 | } 69 | 70 | func (pss *PageSessionStore) mapGet(id string) *PageSession { 71 | ps, _ := pss.sessions.Get(id) 72 | 73 | return ps 74 | } 75 | 76 | func (pss *PageSessionStore) mapDelete(id string) { 77 | if pss.sessions.Del(id) { 78 | atomic.AddUint32(&pss.sessionCount, ^uint32(0)) 79 | } 80 | } 81 | 82 | func (pss *PageSessionStore) GarbageCollection() { 83 | for { 84 | time.Sleep(pss.GarbageCollectionTick) 85 | 86 | select { 87 | case <-pss.Done: 88 | return 89 | default: 90 | now := time.Now() 91 | pss.sessions.Range(func(id string, sess *PageSession) bool { 92 | if sess.IsConnected() { 93 | return true 94 | } 95 | 96 | // Keep until it exceeds the timeout 97 | sess.muSess.RLock() 98 | la := sess.lastActive 99 | sess.muSess.RUnlock() 100 | 101 | if now.Sub(la) > pss.DisconnectTimeout { 102 | if sess.page != nil { 103 | sess.page.Close(sess.ctxPage) 104 | } 105 | 106 | if sess.ctxInitialCancel != nil { 107 | sess.ctxInitialCancel() 108 | } 109 | 110 | close(sess.done) 111 | pss.mapDelete(id) 112 | } 113 | 114 | return true 115 | }) 116 | } 117 | } 118 | } 119 | 120 | func (pss *PageSessionStore) Delete(id string) { 121 | ps := pss.mapGet(id) 122 | if ps == nil { 123 | return 124 | } 125 | 126 | if ps.GetPage() != nil { 127 | ps.GetPage().Close(ps.GetContextPage()) 128 | } 129 | 130 | pss.mapDelete(id) 131 | } 132 | 133 | func (pss *PageSessionStore) GetSessionCount() int { 134 | return int(atomic.LoadUint32(&pss.sessionCount)) 135 | } 136 | -------------------------------------------------------------------------------- /_example/session/session.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/http" 8 | 9 | l "github.com/SamHennessy/hlive" 10 | "github.com/rs/xid" 11 | ) 12 | 13 | func main() { 14 | s := newService() 15 | 16 | http.HandleFunc("/", sessionMiddleware(home(s).ServeHTTP)) 17 | 18 | log.Println("INFO: listing :3000") 19 | 20 | if err := http.ListenAndServe(":3000", nil); err != nil { 21 | log.Println("ERRO: http listen and serve: ", err) 22 | } 23 | } 24 | 25 | func home(s *service) *l.PageServer { 26 | f := func() *l.Page { 27 | page := l.NewPage() 28 | page.DOM().Title().Add("HTTP Session Example") 29 | page.DOM().Head().Add(l.T("link", l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 30 | 31 | page.DOM().Body().Add( 32 | l.T("header", 33 | l.T("h1", "HTTP Session"), 34 | l.T("p", "You can use middleware to implement a persistent session."), 35 | ), 36 | l.T("main", 37 | l.T("p", "Enter a message, then open another tab to see it there."), 38 | l.T("h2", "Your Message"), 39 | newMessage(s), 40 | l.T("p", 41 | "This example uses a cookie and server memory to persist between page reloads but not server reloads. Changes are not synced between tabs in real-time."), 42 | l.T("p", "Be careful when testing in Firefox, as it will keep the current form value on refresh."), 43 | ), 44 | ) 45 | 46 | return page 47 | } 48 | 49 | return l.NewPageServer(f) 50 | } 51 | 52 | type ctxKey string 53 | 54 | const sessionKey ctxKey = "session" 55 | 56 | func sessionMiddleware(h http.HandlerFunc) http.HandlerFunc { 57 | cookieName := "hlive_session" 58 | 59 | return func(w http.ResponseWriter, r *http.Request) { 60 | var sessionID string 61 | 62 | cook, err := r.Cookie(cookieName) 63 | 64 | switch { 65 | case errors.Is(err, http.ErrNoCookie): 66 | sessionID = xid.New().String() 67 | 68 | http.SetCookie(w, 69 | &http.Cookie{Name: cookieName, Value: sessionID, Path: "/", SameSite: http.SameSiteStrictMode}) 70 | case err != nil: 71 | log.Println("ERROR: get cookie: ", err.Error()) 72 | default: 73 | sessionID = cook.Value 74 | } 75 | 76 | r = r.WithContext(context.WithValue(r.Context(), sessionKey, sessionID)) 77 | 78 | h(w, r) 79 | } 80 | } 81 | 82 | func getSessionID(ctx context.Context) string { 83 | val, _ := ctx.Value(sessionKey).(string) 84 | 85 | return val 86 | } 87 | 88 | func newService() *service { 89 | return &service{userMessage: map[string]string{}} 90 | } 91 | 92 | type service struct { 93 | userMessage map[string]string 94 | } 95 | 96 | func (s *service) SetMessage(userID, message string) { 97 | s.userMessage[userID] = message 98 | } 99 | 100 | func (s *service) GetMessage(userID string) string { 101 | return s.userMessage[userID] 102 | } 103 | 104 | func newMessage(service *service) *message { 105 | c := &message{ 106 | Component: l.C("textarea"), 107 | service: service, 108 | } 109 | 110 | c.Add(l.On("input", func(ctx context.Context, e l.Event) { 111 | c.service.SetMessage(getSessionID(ctx), e.Value) 112 | })) 113 | 114 | return c 115 | } 116 | 117 | type message struct { 118 | *l.Component 119 | 120 | Message string 121 | 122 | service *service 123 | } 124 | 125 | func (c *message) Mount(ctx context.Context) { 126 | c.Message = c.service.GetMessage(getSessionID(ctx)) 127 | } 128 | 129 | func (c *message) GetNodes() *l.NodeGroup { 130 | return l.Group(c.Message) 131 | } 132 | -------------------------------------------------------------------------------- /hlivekit/pubsub_test.go: -------------------------------------------------------------------------------- 1 | package hlivekit_test 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/SamHennessy/hlive/hlivekit" 8 | "github.com/go-test/deep" 9 | "github.com/teris-io/shortid" 10 | ) 11 | 12 | type testSubscriber struct { 13 | id string 14 | called bool 15 | calledTopic string 16 | calledValue any 17 | wait sync.WaitGroup 18 | } 19 | 20 | func newSub() *testSubscriber { 21 | sub := &testSubscriber{id: shortid.MustGenerate()} 22 | sub.wait.Add(1) 23 | 24 | return sub 25 | } 26 | 27 | func (s *testSubscriber) OnMessage(message hlivekit.QueueMessage) { 28 | s.called = true 29 | s.calledTopic = message.Topic 30 | s.calledValue = message.Value 31 | s.wait.Done() 32 | } 33 | 34 | func (s *testSubscriber) GetID() string { 35 | return s.id 36 | } 37 | 38 | func TestNewPubSub(t *testing.T) { 39 | t.Parallel() 40 | 41 | if hlivekit.NewPubSub() == nil { 42 | t.Error("nil returned") 43 | } 44 | } 45 | 46 | func TestPubSub_PublishAndSubscribe(t *testing.T) { 47 | t.Parallel() 48 | 49 | sub := newSub() 50 | ps := hlivekit.NewPubSub() 51 | 52 | ps.SubscribeWait(sub, "topic_1") 53 | 54 | ps.Publish("topic_2", nil) 55 | 56 | if sub.called { 57 | t.Fatal("unexpected sub call") 58 | } 59 | 60 | ps.Publish("topic_1", "foo") 61 | 62 | if !sub.called { 63 | t.Fatal("expected sub call") 64 | } 65 | 66 | if diff := deep.Equal("foo", sub.calledValue); diff != nil { 67 | t.Error(diff) 68 | } 69 | } 70 | 71 | func TestPubSub_PublishAsync(t *testing.T) { 72 | t.Parallel() 73 | 74 | sub := newSub() 75 | ps := hlivekit.NewPubSub() 76 | 77 | ps.SubscribeWait(sub, "topic_1") 78 | 79 | go ps.Publish("topic_1", nil) 80 | 81 | sub.wait.Wait() 82 | 83 | if !sub.called { 84 | t.Fatal("expected sub call") 85 | } 86 | } 87 | 88 | func TestPubSub_SubscribeMultiTopic(t *testing.T) { 89 | t.Parallel() 90 | 91 | sub := newSub() 92 | ps := hlivekit.NewPubSub() 93 | 94 | ps.SubscribeWait(sub, "topic_1", "topic_2") 95 | 96 | ps.Publish("topic_1", nil) 97 | 98 | if !sub.called || sub.calledTopic != "topic_1" { 99 | t.Fatal("expected sub call") 100 | } 101 | 102 | sub.called = false 103 | sub.wait.Add(1) 104 | 105 | ps.Publish("topic_2", nil) 106 | 107 | if !sub.called || sub.calledTopic != "topic_2" { 108 | t.Fatal("expected sub call") 109 | } 110 | } 111 | 112 | func TestPubSub_Unsubscribe(t *testing.T) { 113 | t.Parallel() 114 | 115 | sub := newSub() 116 | ps := hlivekit.NewPubSub() 117 | 118 | ps.SubscribeWait(sub, "topic_1") 119 | 120 | ps.UnsubscribeWait(sub, "topic_1") 121 | 122 | ps.Publish("topic_1", nil) 123 | 124 | if sub.called { 125 | t.Fatal("unexpected sub call") 126 | } 127 | } 128 | 129 | func TestPubSub_UnsubscribeOneOfMulti(t *testing.T) { 130 | t.Parallel() 131 | 132 | sub := newSub() 133 | ps := hlivekit.NewPubSub() 134 | 135 | ps.SubscribeWait(sub, "topic_1", "topic_2") 136 | 137 | ps.UnsubscribeWait(sub, "topic_1") 138 | 139 | ps.Publish("topic_1", nil) 140 | 141 | if sub.called { 142 | t.Fatal("unexpected sub call") 143 | } 144 | 145 | ps.Publish("topic_2", nil) 146 | 147 | if !sub.called { 148 | t.Fatal("expected sub call") 149 | } 150 | } 151 | 152 | func TestPubSub_UnsubscribeMulti(t *testing.T) { 153 | t.Parallel() 154 | 155 | sub := newSub() 156 | ps := hlivekit.NewPubSub() 157 | 158 | ps.SubscribeWait(sub, "topic_1", "topic_2") 159 | 160 | ps.UnsubscribeWait(sub, "topic_1", "topic_2") 161 | 162 | ps.Publish("topic_1", nil) 163 | 164 | if sub.called { 165 | t.Fatal("unexpected sub call") 166 | } 167 | 168 | ps.Publish("topic_2", nil) 169 | 170 | if sub.called { 171 | t.Fatal("unexpected sub call") 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /pageServer.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/gorilla/websocket" 9 | "github.com/rs/zerolog" 10 | "github.com/vmihailenco/msgpack/v5" 11 | ) 12 | 13 | func NewPageServer(pf func() *Page) *PageServer { 14 | return NewPageServerWithSessionStore(pf, NewPageSessionStore()) 15 | } 16 | 17 | func NewPageServerWithSessionStore(pf func() *Page, sess *PageSessionStore) *PageServer { 18 | return &PageServer{ 19 | pageFunc: pf, 20 | Sessions: sess, 21 | logger: zerolog.Nop(), 22 | } 23 | } 24 | 25 | type PageServer struct { 26 | Sessions *PageSessionStore 27 | Upgrader websocket.Upgrader 28 | 29 | pageFunc func() *Page 30 | logger zerolog.Logger 31 | } 32 | 33 | func (s *PageServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 34 | // WebSocket? 35 | sessID := r.URL.Query().Get("hlive") 36 | 37 | if sessID == "" { 38 | s.pageFunc().ServeHTTP(w, r) 39 | 40 | return 41 | } 42 | 43 | var sess *PageSession 44 | // New 45 | if sessID == "1" { 46 | sess = s.Sessions.New() 47 | sess.muSess.Lock() 48 | sess.page = s.pageFunc() 49 | sess.connectedAt = time.Now() 50 | sess.lastActive = sess.connectedAt 51 | sess.ctxInitial, sess.ctxInitialCancel = context.WithCancel(r.Context()) 52 | sess.ctxPage, sess.ctxPageCancel = context.WithCancel(sess.ctxInitial) 53 | sess.muSess.Unlock() 54 | } else { // Reconnect 55 | // TODO: need to rethink reconnect and double check my assumptions 56 | //sess = s.Sessions.Get(sessID) 57 | // 58 | //if sess != nil && sess.IsConnected() { 59 | // LoggerDev.Error().Str("sessID", sessID). 60 | // Msg("ws connect: is connected: connection blocked as an active connection exists") 61 | // 62 | // w.WriteHeader(http.StatusNotFound) 63 | // 64 | // return 65 | //} 66 | 67 | //if sess != nil { 68 | // 69 | // //sess.GetInitialContextCancel()() 70 | // 71 | // //sess.muSess.Lock() 72 | // 73 | // //sess.ctxInitial, sess.ctxInitialCancel = context.WithCancel(r.Context()) 74 | // //sess.ctxPage, sess.ctxPageCancel = context.WithCancel(sess.ctxInitial) 75 | // 76 | // //sess.muSess.Unlock() 77 | //} 78 | } 79 | 80 | // LoggerDev.Debug().Str("sessID", sessID).Bool("success", sess != nil).Msg("ws connect") 81 | 82 | if sess == nil { 83 | w.WriteHeader(http.StatusNotFound) 84 | 85 | return 86 | } 87 | 88 | hhash := r.URL.Query().Get("hhash") 89 | 90 | s.logger = sess.GetPage().logger 91 | s.logger.Debug().Str("sessionID", sessID).Str("hash", hhash).Msg("ws start") 92 | 93 | if sess.GetPage().cache != nil && hhash != "" && sessID == "1" { 94 | val, hit := sess.GetPage().cache.Get(hhash) 95 | 96 | b, ok := val.([]byte) 97 | if hit && ok { 98 | s.logger.Debug().Bool("hit", hit).Str("hhash", hhash).Int("size", len(b)/1024). 99 | Msg("cache get") 100 | newTree := G() 101 | if err := msgpack.Unmarshal(b, newTree); err != nil { 102 | s.logger.Err(err).Msg("ServeHTTP: msgpack.Unmarshal") 103 | } else { 104 | sess.GetPage().domBrowser = newTree 105 | } 106 | } 107 | } 108 | 109 | sess.muSess.Lock() 110 | 111 | var err error 112 | sess.wsConn, err = s.Upgrader.Upgrade(w, r, nil) 113 | if err != nil { 114 | sess.muSess.Unlock() 115 | s.logger.Err(err).Msg("ws upgrade") 116 | w.WriteHeader(http.StatusInternalServerError) 117 | 118 | return 119 | } 120 | 121 | sess.connected = true 122 | sess.lastActive = time.Now() 123 | 124 | sess.muSess.Unlock() 125 | 126 | go sess.writePump() 127 | go sess.readPump() 128 | 129 | if err := sess.GetPage().ServeWS(sess.GetContextPage(), sess.GetID(), sess.Send, sess.Receive); err != nil { 130 | sess.GetPage().logger.Err(err).Msg("ws serve") 131 | w.WriteHeader(http.StatusInternalServerError) 132 | } 133 | 134 | // This needs to say open to keep the context active 135 | <-sess.done 136 | } 137 | -------------------------------------------------------------------------------- /_example/todo/todo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | l "github.com/SamHennessy/hlive" 9 | "github.com/SamHennessy/hlive/hlivekit" 10 | ) 11 | 12 | func main() { 13 | http.Handle("/", l.NewPageServer(home)) 14 | 15 | log.Println("INFO: listing :3000") 16 | 17 | if err := http.ListenAndServe(":3000", nil); err != nil { 18 | log.Println("ERRO: http listen and serve:", err) 19 | } 20 | } 21 | 22 | func home() *l.Page { 23 | page := l.NewPage() 24 | page.DOM().Title().Add("To Do Example") 25 | page.DOM().Head().Add(l.T("link", 26 | l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 27 | 28 | page.DOM().Body().Add( 29 | l.T("header", 30 | l.T("h1", "To Do App Example"), 31 | l.T("p", "A simple app where you can add and remove elements"), 32 | ), 33 | l.T("main", 34 | newTodoApp().tree, 35 | ), 36 | ) 37 | 38 | return page 39 | } 40 | 41 | type todoApp struct { 42 | newTask *l.NodeBox[string] 43 | newTaskInput *l.Component 44 | taskList *hlivekit.ComponentList 45 | tree []l.Tagger 46 | } 47 | 48 | func newTodoApp() *todoApp { 49 | app := &todoApp{ 50 | newTask: l.Box(""), 51 | } 52 | app.init() 53 | 54 | return app 55 | } 56 | 57 | func (a *todoApp) init() { 58 | a.initForm() 59 | a.initList() 60 | } 61 | 62 | func (a *todoApp) initForm() { 63 | a.newTaskInput = l.C("input", l.Attrs{"type": "text", "placeholder": "Task E.g: Buy Food, Walk dog, ..."}) 64 | a.newTaskInput.Add( 65 | l.On("input", func(_ context.Context, e l.Event) { 66 | a.newTask.Set(e.Value) 67 | // This is needed to allow us to clear the input on submit 68 | // Without this there would be no difference in the tree to trigger a diff 69 | a.newTaskInput.Add(l.Attrs{"value": e.Value}) 70 | }), 71 | ) 72 | 73 | f := l.C("form", 74 | l.PreventDefault(), 75 | l.On("submit", func(ctx context.Context, _ l.Event) { 76 | a.addTask(a.newTask.Get()) 77 | // Clear input 78 | a.newTask.Set("") 79 | a.newTaskInput.Add(l.Attrs{"value": ""}) 80 | }), 81 | l.T("p", "Task Label"), 82 | a.newTaskInput, " ", 83 | l.T("button", "Add"), 84 | ) 85 | 86 | a.tree = append(a.tree, f) 87 | } 88 | 89 | func (a *todoApp) initList() { 90 | a.taskList = hlivekit.List("div") 91 | a.tree = append(a.tree, 92 | l.T("h3", "To Do List:"), 93 | a.taskList, 94 | ) 95 | } 96 | 97 | func (a *todoApp) addTask(label string) { 98 | labelBox := l.Box(label) 99 | // This is a ComponentMountable. This allows the list to do clean up when we remove it. 100 | container := l.CM("div") 101 | labelSpan := l.T("span", labelBox) 102 | 103 | labelInput := l.C("input", 104 | l.Attrs{"type": "text"}, 105 | l.AttrsLockBox{"value": labelBox.LockBox}, 106 | l.On("input", func(_ context.Context, e l.Event) { 107 | labelBox.Set(e.Value) 108 | }), 109 | ) 110 | 111 | // Prevent a server side render on each keypress as we don't have anything in the view that updates on keypress of 112 | // this input 113 | labelInput.AutoRender = false 114 | 115 | labelForm := l.C("form", 116 | l.PreventDefault(), 117 | l.Style{"display": "none"}, 118 | l.On("submit", func(_ context.Context, e l.Event) { 119 | // You can get back to the bound component from the event 120 | lf, ok := e.Binding.Component.(*l.Component) 121 | if !ok { 122 | return 123 | } 124 | 125 | lf.Add(l.Style{"display": "none"}) 126 | labelSpan.Add(l.StyleOff{"display"}) 127 | }), 128 | 129 | labelInput, " ", 130 | l.T("button", "Update"), 131 | ) 132 | 133 | container.Add( 134 | // Delete button 135 | l.C("button", "🗑️", l.On("click", func(_ context.Context, _ l.Event) { 136 | a.taskList.RemoveItems(container) 137 | })), " ", 138 | // Edit button 139 | l.C("button", "✏️", l.On("click", func(_ context.Context, _ l.Event) { 140 | labelSpan.Add(l.Style{"display": "none"}) 141 | labelForm.Add(l.StyleOff{"display"}) 142 | labelInput.Add(hlivekit.Focus(), l.OnOnce("focus", func(ctx context.Context, _ l.Event) { 143 | hlivekit.FocusRemove(labelInput) 144 | 145 | l.Render(ctx) 146 | })) 147 | })), " ", 148 | labelSpan, 149 | labelForm, 150 | ) 151 | 152 | a.taskList.AddItem(container) 153 | } 154 | -------------------------------------------------------------------------------- /renderer_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | "github.com/go-test/deep" 9 | ) 10 | 11 | func TestRenderer_RenderElementTagCSS(t *testing.T) { 12 | t.Parallel() 13 | 14 | el := l.T("hr", 15 | l.ClassBool{"c3": true}, 16 | l.ClassBool{"c2": true}, 17 | l.ClassBool{"c1": true}, 18 | l.ClassBool{"c2": false}) 19 | buff := bytes.NewBuffer(nil) 20 | 21 | if err := l.NewRenderer().HTML(buff, el); err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | if diff := deep.Equal(`
`, buff.String()); diff != nil { 26 | t.Error(diff) 27 | } 28 | } 29 | 30 | func TestRenderer_RenderElementText(t *testing.T) { 31 | t.Parallel() 32 | 33 | buff := bytes.NewBuffer(nil) 34 | if err := l.NewRenderer().HTML(buff, "

text_test

"); err != nil { 35 | t.Fatal(err) 36 | } 37 | 38 | if diff := deep.Equal("<h1>text_test</h1>", buff.String()); diff != nil { 39 | t.Error(diff) 40 | } 41 | } 42 | 43 | func TestRenderer_RenderElementRawHTML(t *testing.T) { 44 | t.Parallel() 45 | 46 | el := l.HTML("

html_test

") 47 | buff := bytes.NewBuffer(nil) 48 | 49 | if err := l.NewRenderer().HTML(buff, el); err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | if diff := deep.Equal("

html_test

", buff.String()); diff != nil { 54 | t.Error(diff) 55 | } 56 | } 57 | 58 | func TestRenderer_RenderElementTag(t *testing.T) { 59 | t.Parallel() 60 | 61 | el := l.NewTag("a") 62 | buff := bytes.NewBuffer(nil) 63 | 64 | if err := l.NewRenderer().HTML(buff, el); err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | if diff := deep.Equal("", buff.String()); diff != nil { 69 | t.Error(diff) 70 | } 71 | } 72 | 73 | func TestRenderer_RenderElementTagAttr(t *testing.T) { 74 | t.Parallel() 75 | 76 | el := l.NewTag("a", l.NewAttribute("href", "https://example.com")) 77 | buff := bytes.NewBuffer(nil) 78 | 79 | if err := l.NewRenderer().HTML(buff, el); err != nil { 80 | t.Fatal(err) 81 | } 82 | 83 | if diff := deep.Equal(``, buff.String()); diff != nil { 84 | t.Error(diff) 85 | } 86 | } 87 | 88 | func TestRenderer_RenderElementTagAttrs(t *testing.T) { 89 | t.Parallel() 90 | 91 | el := l.NewTag("a", l.Attrs{"href": "https://example.com"}) 92 | buff := bytes.NewBuffer(nil) 93 | 94 | if err := l.NewRenderer().HTML(buff, el); err != nil { 95 | t.Fatal(err) 96 | } 97 | 98 | if diff := deep.Equal(``, buff.String()); diff != nil { 99 | t.Error(diff) 100 | } 101 | } 102 | 103 | func TestRenderer_RenderElementTagChildText(t *testing.T) { 104 | t.Parallel() 105 | 106 | el := l.NewTag("a", "

text_test

") 107 | buff := bytes.NewBuffer(nil) 108 | 109 | if err := l.NewRenderer().HTML(buff, el); err != nil { 110 | t.Fatal(err) 111 | } 112 | 113 | if diff := deep.Equal(`<h1>text_test</h1>`, buff.String()); diff != nil { 114 | t.Error(diff) 115 | } 116 | } 117 | 118 | func TestRenderer_RenderElementTagChildTag(t *testing.T) { 119 | t.Parallel() 120 | 121 | el := l.NewTag("a", 122 | l.NewTag("h1", "text_test"), 123 | ) 124 | buff := bytes.NewBuffer(nil) 125 | 126 | if err := l.NewRenderer().HTML(buff, el); err != nil { 127 | t.Fatal(err) 128 | } 129 | 130 | if diff := deep.Equal(`

text_test

`, buff.String()); diff != nil { 131 | t.Error(diff) 132 | } 133 | } 134 | 135 | func TestRenderer_RenderElementTagVoid(t *testing.T) { 136 | t.Parallel() 137 | 138 | el := l.T("hr", l.Attrs{"foo": "bar"}) 139 | buff := bytes.NewBuffer(nil) 140 | 141 | if err := l.NewRenderer().HTML(buff, el); err != nil { 142 | t.Fatal(err) 143 | } 144 | 145 | if diff := deep.Equal(`
`, buff.String()); diff != nil { 146 | t.Error(diff) 147 | } 148 | } 149 | 150 | func TestRenderer_Attribute(t *testing.T) { 151 | t.Parallel() 152 | 153 | tests := []struct { 154 | name string 155 | attrs l.Attrs 156 | wantW string 157 | wantErr bool 158 | }{ 159 | { 160 | "simple", 161 | l.Attrs{"foo": "bar"}, 162 | ` foo="bar"`, 163 | false, 164 | }, 165 | { 166 | "empty", 167 | l.Attrs{"foo": ""}, 168 | ` foo=""`, 169 | false, 170 | }, 171 | { 172 | "json", 173 | l.Attrs{"foo": `["key1"]`}, 174 | ` foo="["key1"]"`, 175 | false, 176 | }, 177 | } 178 | 179 | for _, tt := range tests { 180 | tt := tt 181 | t.Run(tt.name, func(t *testing.T) { 182 | t.Parallel() 183 | 184 | r := l.NewRenderer() 185 | w := &bytes.Buffer{} 186 | if err := r.Attribute(tt.attrs.GetAttributers(), w); (err != nil) != tt.wantErr { 187 | t.Errorf("Attribute() error = %v, wantErr %v", err, tt.wantErr) 188 | 189 | return 190 | } 191 | 192 | if gotW := w.String(); gotW != tt.wantW { 193 | t.Errorf("Attribute() gotW = %v, want %v", gotW, tt.wantW) 194 | } 195 | }) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /differ_test.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import "testing" 4 | 5 | func Test_pathGreater(t *testing.T) { 6 | type args struct { 7 | pathA string 8 | pathB string 9 | } 10 | tests := []struct { 11 | name string 12 | args args 13 | want bool 14 | }{ 15 | { 16 | "both empty", 17 | args{ 18 | pathA: "", 19 | pathB: "", 20 | }, 21 | false, 22 | }, 23 | { 24 | "one level equal", 25 | args{ 26 | pathA: "0", 27 | pathB: "0", 28 | }, 29 | false, 30 | }, 31 | { 32 | "one level greater", 33 | args{ 34 | pathA: "1", 35 | pathB: "0", 36 | }, 37 | true, 38 | }, 39 | { 40 | "one level less", 41 | args{ 42 | pathA: "0", 43 | pathB: "1", 44 | }, 45 | false, 46 | }, 47 | { 48 | "two levels a equal", 49 | args{ 50 | pathA: "0>1", 51 | pathB: "0>1", 52 | }, 53 | false, 54 | }, 55 | { 56 | "two levels a greater", 57 | args{ 58 | pathA: "0>2", 59 | pathB: "0>1", 60 | }, 61 | true, 62 | }, 63 | { 64 | "two levels a less", 65 | args{ 66 | pathA: "0>1", 67 | pathB: "0>2", 68 | }, 69 | false, 70 | }, 71 | { 72 | "three levels a equal", 73 | args{ 74 | pathA: "0>1>2", 75 | pathB: "0>1>2", 76 | }, 77 | false, 78 | }, 79 | { 80 | "three levels a greater", 81 | args{ 82 | pathA: "0>1>2", 83 | pathB: "0>1>1", 84 | }, 85 | true, 86 | }, 87 | { 88 | "three levels a less", 89 | args{ 90 | pathA: "0>1>1", 91 | pathB: "0>1>2", 92 | }, 93 | false, 94 | }, 95 | { 96 | "mid path diff greater", 97 | args{ 98 | pathA: "0>2>1", 99 | pathB: "0>1>1", 100 | }, 101 | true, 102 | }, 103 | { 104 | "mid path diff less", 105 | args{ 106 | pathA: "0>1>1", 107 | pathB: "0>2>1", 108 | }, 109 | false, 110 | }, 111 | { 112 | "mis match length less", 113 | args{ 114 | pathA: "0>1>2>3", 115 | pathB: "0>1>2", 116 | }, 117 | false, 118 | }, 119 | { 120 | "mis match length greater", 121 | args{ 122 | pathA: "0>1>2", 123 | pathB: "0>1>2>3", 124 | }, 125 | false, 126 | }, 127 | { 128 | "real example", 129 | args{ 130 | pathA: "1>1>2>1", 131 | pathB: "1>1>2>2", 132 | }, 133 | false, 134 | }, 135 | } 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | if got := pathGreater(tt.args.pathA, tt.args.pathB); got != tt.want { 139 | t.Errorf("pathGreater() = %v, want %v", got, tt.want) 140 | } 141 | }) 142 | } 143 | } 144 | 145 | func Test_pathLesser(t *testing.T) { 146 | type args struct { 147 | pathA string 148 | pathB string 149 | } 150 | tests := []struct { 151 | name string 152 | args args 153 | want bool 154 | }{ 155 | { 156 | "both empty", 157 | args{ 158 | pathA: "", 159 | pathB: "", 160 | }, 161 | false, 162 | }, 163 | { 164 | "one level equal", 165 | args{ 166 | pathA: "0", 167 | pathB: "0", 168 | }, 169 | false, 170 | }, 171 | { 172 | "one level greater", 173 | args{ 174 | pathA: "1", 175 | pathB: "0", 176 | }, 177 | false, 178 | }, 179 | { 180 | "one level less", 181 | args{ 182 | pathA: "0", 183 | pathB: "1", 184 | }, 185 | true, 186 | }, 187 | { 188 | "two levels a equal", 189 | args{ 190 | pathA: "0>1", 191 | pathB: "0>1", 192 | }, 193 | false, 194 | }, 195 | { 196 | "two levels a greater", 197 | args{ 198 | pathA: "0>2", 199 | pathB: "0>1", 200 | }, 201 | false, 202 | }, 203 | { 204 | "two levels a less", 205 | args{ 206 | pathA: "0>1", 207 | pathB: "0>2", 208 | }, 209 | true, 210 | }, 211 | { 212 | "three levels a equal", 213 | args{ 214 | pathA: "0>1>2", 215 | pathB: "0>1>2", 216 | }, 217 | false, 218 | }, 219 | { 220 | "three levels a greater", 221 | args{ 222 | pathA: "0>1>2", 223 | pathB: "0>1>1", 224 | }, 225 | false, 226 | }, 227 | { 228 | "three levels a less", 229 | args{ 230 | pathA: "0>1>1", 231 | pathB: "0>1>2", 232 | }, 233 | true, 234 | }, 235 | { 236 | "mid path diff greater", 237 | args{ 238 | pathA: "0>2>1", 239 | pathB: "0>1>1", 240 | }, 241 | false, 242 | }, 243 | { 244 | "mid path diff less", 245 | args{ 246 | pathA: "0>1>1", 247 | pathB: "0>2>1", 248 | }, 249 | true, 250 | }, 251 | { 252 | "mis match length less", 253 | args{ 254 | pathA: "0>1>2>3", 255 | pathB: "0>1>2", 256 | }, 257 | true, 258 | }, 259 | { 260 | "mis match length greater", 261 | args{ 262 | pathA: "0>1>2", 263 | pathB: "0>1>2>3", 264 | }, 265 | false, 266 | }, 267 | } 268 | for _, tt := range tests { 269 | t.Run(tt.name, func(t *testing.T) { 270 | if got := pathLesser(tt.args.pathA, tt.args.pathB); got != tt.want { 271 | t.Errorf("pathLesser() = %v, want %v", got, tt.want) 272 | } 273 | }) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /attribute.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/vmihailenco/msgpack/v5" 8 | ) 9 | 10 | type Attributer interface { 11 | GetName() string 12 | GetValue() string 13 | IsNoEscapeString() bool 14 | Clone() *Attribute 15 | } 16 | 17 | type AttributePluginer interface { 18 | Attributer 19 | 20 | // Initialize will only be called once per attribute name for diff render 21 | Initialize(page *Page) 22 | // InitializeSSR will only be called once per attribute name for server side render 23 | InitializeSSR(page *Page) 24 | } 25 | 26 | // Attrs is a helper for adding and updating Attributes to nodes 27 | type Attrs map[string]string 28 | 29 | func (a Attrs) GetAttributers() []Attributer { 30 | newAttrs := make([]Attributer, 0, len(a)) 31 | 32 | for name, val := range a { 33 | newAttrs = append(newAttrs, NewAttribute(name, val)) 34 | } 35 | 36 | return newAttrs 37 | } 38 | 39 | type AttrsLockBox map[string]*LockBox[string] 40 | 41 | func (a AttrsLockBox) GetAttributers() []Attributer { 42 | newAttrs := make([]Attributer, 0, len(a)) 43 | 44 | for name, val := range a { 45 | newAttrs = append(newAttrs, NewAttributeLockBox(name, val)) 46 | } 47 | 48 | return newAttrs 49 | } 50 | 51 | // AttrsOff a helper for removing Attributes 52 | type AttrsOff []string 53 | 54 | // ClassBool a special Attribute for working with CSS classes on nodes using a bool to toggle them on and off. 55 | // It supports turning them on and off and allowing overriding. Due to how Go maps work the order of the classes in 56 | // the map is not preserved. 57 | // All Classes are de-duped, overriding a Class by adding new ClassBool will result in the old Class getting updated. 58 | // You don't have to use ClassBool to add a class attribute, but it's the recommended way to do it. 59 | type ClassBool map[string]bool 60 | 61 | // TODO: add tests and docs 62 | type ( 63 | Class string 64 | ClassOff string 65 | ClassList []string 66 | ClassListOff []string 67 | ) 68 | 69 | // Style is a special Element that allows you to work the properties of style attribute. 70 | // A property and value will be added or updated. 71 | // You don't have to use Style to add a style attribute, but it's the recommended way to do it. 72 | type Style map[string]string 73 | 74 | // StyleLockBox like Style but, you can update the property values indirectly 75 | // TODO: add test 76 | type StyleLockBox map[string]*LockBox[string] 77 | 78 | // StyleOff remove an existing style property, ignored if the property doesn't exist 79 | // TODO: add test 80 | type StyleOff []string 81 | 82 | // NewAttribute create a new Attribute 83 | func NewAttribute(name string, value string) *Attribute { 84 | return &Attribute{name: strings.ToLower(name), value: NewLockBox(value)} 85 | } 86 | 87 | // NewAttributeLockBox create a new Attribute using the passed LockBox value 88 | func NewAttributeLockBox(name string, value *LockBox[string]) *Attribute { 89 | return &Attribute{name: strings.ToLower(name), value: value} 90 | } 91 | 92 | // Attribute represents an HTML attribute e.g. id="submitBtn" 93 | type Attribute struct { 94 | // name must always be lowercase 95 | name string 96 | value *LockBox[string] 97 | noEscapeString bool 98 | } 99 | 100 | func (a *Attribute) MarshalMsgpack() ([]byte, error) { 101 | return msgpack.Marshal([2]any{a.name, a.value.Get()}) 102 | } 103 | 104 | func (a *Attribute) UnmarshalMsgpack(b []byte) error { 105 | var values [2]any 106 | if err := msgpack.Unmarshal(b, &values); err != nil { 107 | return fmt.Errorf("msgpack.Unmarshal: %w", err) 108 | } 109 | 110 | a.name, _ = values[0].(string) 111 | 112 | val, _ := values[1].(string) 113 | a.value = NewLockBox(val) 114 | 115 | return nil 116 | } 117 | 118 | func (a *Attribute) GetName() string { 119 | if a == nil { 120 | return "" 121 | } 122 | 123 | return a.name 124 | } 125 | 126 | func (a *Attribute) SetValue(value string) { 127 | a.value.Set(value) 128 | } 129 | 130 | func (a *Attribute) GetValue() string { 131 | if a == nil { 132 | return "" 133 | } 134 | 135 | return a.value.Get() 136 | } 137 | 138 | // Clone creates a new Attribute using the data from this Attribute 139 | func (a *Attribute) Clone() *Attribute { 140 | newA := NewAttribute(a.name, a.value.Get()) 141 | newA.noEscapeString = a.noEscapeString 142 | 143 | return newA 144 | } 145 | 146 | func (a *Attribute) IsNoEscapeString() bool { 147 | if a == nil { 148 | return false 149 | } 150 | 151 | return a.noEscapeString 152 | } 153 | 154 | func (a *Attribute) SetNoEscapeString(noEscapeString bool) { 155 | a.noEscapeString = noEscapeString 156 | } 157 | 158 | func anyToAttributes(attrs ...any) []Attributer { 159 | var newAttrs []Attributer 160 | 161 | for i := 0; i < len(attrs); i++ { 162 | if attrs[i] == nil { 163 | continue 164 | } 165 | 166 | switch v := attrs[i].(type) { 167 | case Attrs: 168 | newAttrs = append(newAttrs, v.GetAttributers()...) 169 | case AttrsLockBox: 170 | newAttrs = append(newAttrs, v.GetAttributers()...) 171 | case Attributer: 172 | newAttrs = append(newAttrs, v) 173 | case []Attributer: 174 | newAttrs = append(newAttrs, v...) 175 | case []*Attribute: 176 | for j := 0; j < len(v); j++ { 177 | newAttrs = append(newAttrs, v[j]) 178 | } 179 | default: 180 | LoggerDev.Error(). 181 | Str("callers", CallerStackStr()). 182 | Str("value", fmt.Sprintf("%#v", v)). 183 | Msg("invalid attribute") 184 | 185 | continue 186 | } 187 | } 188 | 189 | return newAttrs 190 | } 191 | -------------------------------------------------------------------------------- /component.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // Componenter builds on UniqueTagger and adds the ability to handle events. 9 | type Componenter interface { 10 | UniqueTagger 11 | // GetEventBinding returns a binding by its id 12 | GetEventBinding(id string) *EventBinding 13 | // GetEventBindings returns all event bindings for this tag 14 | GetEventBindings() []*EventBinding 15 | // RemoveEventBinding remove an event binding from this component 16 | RemoveEventBinding(id string) 17 | // IsAutoRender indicates if the page should rerender after an event binding on this tag is called 18 | IsAutoRender() bool 19 | } 20 | 21 | // Component is the default implementation of Componenter. 22 | type Component struct { 23 | *Tag 24 | 25 | AutoRender bool 26 | id string 27 | bindingID uint32 28 | bindings []*EventBinding 29 | } 30 | 31 | // C is a shortcut for NewComponent. 32 | // 33 | // NewComponent is a constructor for Component. 34 | // 35 | // You can add zero or many Attributes and Tags 36 | func C(name string, elements ...any) *Component { 37 | return NewComponent(name, elements...) 38 | } 39 | 40 | // NewComponent is a constructor for Component. 41 | // 42 | // You can add zero or many Attributes and Tags. 43 | func NewComponent(name string, elements ...any) *Component { 44 | c := &Component{ 45 | Tag: T(name), 46 | AutoRender: true, 47 | } 48 | 49 | c.Add(elements...) 50 | 51 | return c 52 | } 53 | 54 | // W is a shortcut for Wrap. 55 | // 56 | // Wrap takes a Tag and creates a Component with it. 57 | func W(tag *Tag, elements ...any) *Component { 58 | return Wrap(tag, elements...) 59 | } 60 | 61 | // Wrap takes a Tag and creates a Component with it. 62 | func Wrap(tag *Tag, elements ...any) *Component { 63 | c := &Component{ 64 | Tag: tag, 65 | AutoRender: true, 66 | } 67 | 68 | c.Add(elements...) 69 | 70 | return c 71 | } 72 | 73 | // GetID returns this component's unique ID 74 | func (c *Component) GetID() string { 75 | c.Tag.mu.RLock() 76 | defer c.Tag.mu.RUnlock() 77 | 78 | return c.id 79 | } 80 | 81 | // SetID component's unique ID 82 | func (c *Component) SetID(id string) { 83 | c.Tag.mu.Lock() 84 | defer c.Tag.mu.Unlock() 85 | 86 | c.id = id 87 | c.Tag.addAttributes(NewAttribute(AttrID, id)) 88 | 89 | if value := c.bindingAttrValue(); value != "" { 90 | c.Tag.addAttributes(NewAttribute(AttrOn, value)) 91 | } 92 | } 93 | 94 | func (c *Component) bindingAttrValue() string { 95 | var value string 96 | for i := 0; i < len(c.bindings); i++ { 97 | if c.bindings[i].ID == "" { 98 | c.bindingID++ 99 | c.bindings[i].ID = c.id + "-" + strconv.FormatUint(uint64(c.bindingID), 10) 100 | } 101 | 102 | value += c.bindings[i].ID + "|" + c.bindings[i].Name + "," 103 | } 104 | 105 | return strings.TrimRight(value, ",") 106 | } 107 | 108 | // IsAutoRender indicates if this component should trigger "Auto Render" 109 | func (c *Component) IsAutoRender() bool { 110 | c.Tag.mu.RLock() 111 | defer c.Tag.mu.RUnlock() 112 | 113 | return c.AutoRender 114 | } 115 | 116 | // GetEventBinding will return an EventBinding that exists directly on this element, it doesn't check its children. 117 | // Returns nil is not found. 118 | func (c *Component) GetEventBinding(id string) *EventBinding { 119 | c.Tag.mu.RLock() 120 | defer c.Tag.mu.RUnlock() 121 | 122 | for i := 0; i < len(c.bindings); i++ { 123 | if c.bindings[i].ID == id { 124 | return c.bindings[i] 125 | } 126 | } 127 | 128 | return nil 129 | } 130 | 131 | // GetEventBindings returns all EventBindings for this component, not it's children. 132 | func (c *Component) GetEventBindings() []*EventBinding { 133 | c.Tag.mu.RLock() 134 | defer c.Tag.mu.RUnlock() 135 | 136 | return append([]*EventBinding{}, c.bindings...) 137 | } 138 | 139 | // RemoveEventBinding removes an EventBinding that matches the passed ID. 140 | // 141 | // No error if the passed id doesn't match an EventBinding. 142 | // It doesn't check its children. 143 | func (c *Component) RemoveEventBinding(id string) { 144 | c.Tag.mu.Lock() 145 | defer c.Tag.mu.Unlock() 146 | 147 | var newList []*EventBinding 148 | for i := 0; i < len(c.bindings); i++ { 149 | if c.bindings[i].ID == id { 150 | continue 151 | } 152 | 153 | newList = append(newList, c.bindings[i]) 154 | } 155 | c.bindings = newList 156 | 157 | // Reset attribute 158 | if value := c.bindingAttrValue(); value == "" { 159 | c.removeAttributes(AttrOn) 160 | } else { 161 | c.Tag.addAttributes(NewAttribute(AttrOn, value)) 162 | } 163 | } 164 | 165 | // Add an element to this Component. 166 | // 167 | // This is an easy way to add anything. 168 | func (c *Component) Add(elements ...any) { 169 | if c.IsNil() { 170 | return 171 | } 172 | 173 | for i := 0; i < len(elements); i++ { 174 | switch v := elements[i].(type) { 175 | // NoneNodeElements 176 | case []any: 177 | for j := 0; j < len(v); j++ { 178 | c.Add(v[j]) 179 | } 180 | case *NodeGroup: 181 | if v == nil { 182 | continue 183 | } 184 | 185 | list := v.Get() 186 | for j := 0; j < len(list); j++ { 187 | c.Add(list[j]) 188 | } 189 | case *ElementGroup: 190 | if v == nil { 191 | continue 192 | } 193 | 194 | list := v.Get() 195 | for j := 0; j < len(list); j++ { 196 | c.Add(list[j]) 197 | } 198 | case *EventBinding: 199 | if v == nil { 200 | continue 201 | } 202 | 203 | c.Tag.mu.Lock() 204 | c.on(v) 205 | c.Tag.mu.Unlock() 206 | default: 207 | c.Tag.Add(v) 208 | } 209 | } 210 | } 211 | 212 | func (c *Component) on(binding *EventBinding) { 213 | binding.Component = c 214 | c.bindings = append(c.bindings, binding) 215 | 216 | // See Component.SetID 217 | if c.id == "" { 218 | return 219 | } 220 | 221 | if value := c.bindingAttrValue(); value != "" { 222 | c.Tag.addAttributes(NewAttribute(AttrOn, value)) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /pipelineProcessor.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "strconv" 7 | "sync/atomic" 8 | 9 | "github.com/cornelk/hashmap" 10 | ) 11 | 12 | const ( 13 | PipelineProcessorKeyStripHLiveAttrs = "hlive_strip_hlive_attr" 14 | PipelineProcessorKeyRenderer = "hlive_renderer" 15 | PipelineProcessorKeyEventBindingCache = "hlive_eb" 16 | PipelineProcessorKeyAttributePluginMount = "hlive_attr_mount" 17 | PipelineProcessorKeyMount = "hlive_mount" 18 | PipelineProcessorKeyUnmount = "hlive_unmount" 19 | PipelineProcessorKeyConvertToString = "hlive_conv_str" 20 | ) 21 | 22 | type PipelineProcessor struct { 23 | // Will replace an existing processor with the same key. An empty string won't error. 24 | Key string 25 | Disabled bool 26 | BeforeWalk PipeNodegroupHandler 27 | OnSimpleNode PipeNodeHandler 28 | BeforeTagger PipeTaggerHandler 29 | BeforeAttribute PipeAttributerHandler 30 | AfterAttribute PipeAttributerHandler 31 | AfterTagger PipeTagHandler 32 | AfterWalk PipeNodegroupHandler 33 | } 34 | 35 | func NewPipelineProcessor(key string) *PipelineProcessor { 36 | return &PipelineProcessor{Key: key} 37 | } 38 | 39 | func PipelineProcessorEventBindingCache(cache *hashmap.Map[string, *EventBinding]) *PipelineProcessor { 40 | pp := NewPipelineProcessor(PipelineProcessorKeyEventBindingCache) 41 | 42 | pp.BeforeTagger = func(ctx context.Context, w io.Writer, tag Tagger) (Tagger, error) { 43 | if comp, ok := tag.(Componenter); ok { 44 | bindings := comp.GetEventBindings() 45 | 46 | for i := 0; i < len(bindings); i++ { 47 | cache.Set(bindings[i].ID, bindings[i]) 48 | } 49 | } 50 | 51 | return tag, nil 52 | } 53 | 54 | return pp 55 | } 56 | 57 | func PipelineProcessorMount() *PipelineProcessor { 58 | var compID uint64 59 | 60 | pp := NewPipelineProcessor(PipelineProcessorKeyMount) 61 | 62 | pp.BeforeTagger = func(ctx context.Context, w io.Writer, tag Tagger) (Tagger, error) { 63 | if comp, ok := tag.(UniqueTagger); ok && comp.GetID() == "" { 64 | comp.SetID(strconv.FormatUint(atomic.AddUint64(&compID, 1), 10)) 65 | 66 | if comp, ok := tag.(Mounter); ok { 67 | comp.Mount(ctx) 68 | } 69 | } 70 | 71 | return tag, nil 72 | } 73 | 74 | return pp 75 | } 76 | 77 | func PipelineProcessorUnmount(page *Page) *PipelineProcessor { 78 | cache := hashmap.New[string, Unmounter]() 79 | 80 | page.hookClose = append(page.hookClose, func(ctx context.Context, page *Page) { 81 | cache.Range(func(key string, value Unmounter) bool { 82 | if value != nil { 83 | value.Unmount(ctx) 84 | } 85 | 86 | return true 87 | }) 88 | }) 89 | 90 | pp := NewPipelineProcessor(PipelineProcessorKeyUnmount) 91 | 92 | pp.BeforeTagger = func(ctx context.Context, w io.Writer, tag Tagger) (Tagger, error) { 93 | if comp, ok := tag.(Unmounter); ok { 94 | id := comp.GetID() 95 | 96 | if id == "" { 97 | return tag, nil 98 | } 99 | 100 | if cache.Insert(id, comp) { 101 | // A way to remove the key when you delete a Component 102 | if comp, ok := tag.(Teardowner); ok { 103 | comp.AddTeardown(func() { 104 | cache.Del(id) 105 | }) 106 | } 107 | } 108 | } 109 | 110 | return tag, nil 111 | } 112 | 113 | return pp 114 | } 115 | 116 | func PipelineProcessorRenderer(renderer *Renderer) *PipelineProcessor { 117 | pp := NewPipelineProcessor(PipelineProcessorKeyRenderer) 118 | 119 | pp.AfterWalk = func(ctx context.Context, w io.Writer, node *NodeGroup) (*NodeGroup, error) { 120 | return node, renderer.HTML(w, node) 121 | } 122 | 123 | return pp 124 | } 125 | 126 | func PipelineProcessorConvertToString() *PipelineProcessor { 127 | pp := NewPipelineProcessor(PipelineProcessorKeyConvertToString) 128 | 129 | pp.OnSimpleNode = func(ctx context.Context, w io.Writer, node any) (any, error) { 130 | switch v := node.(type) { 131 | case nil: 132 | return nil, nil 133 | case string: 134 | if v == "" { 135 | return nil, nil 136 | } 137 | 138 | return v, nil 139 | case int: 140 | return strconv.Itoa(v), nil 141 | case int64: 142 | return strconv.FormatInt(v, base10), nil 143 | case uint64: 144 | return strconv.FormatUint(v, base10), nil 145 | case float64: 146 | return strconv.FormatFloat(v, 'f', -1, bit64), nil 147 | case float32: 148 | return strconv.FormatFloat(float64(v), 'f', -1, bit32), nil 149 | case int8: 150 | return strconv.FormatInt(int64(v), base10), nil 151 | case int16: 152 | return strconv.FormatInt(int64(v), base10), nil 153 | case int32: 154 | return strconv.FormatInt(int64(v), base10), nil 155 | case uint: 156 | return strconv.FormatUint(uint64(v), base10), nil 157 | case uint8: 158 | return strconv.FormatUint(uint64(v), base10), nil 159 | case uint16: 160 | return strconv.FormatUint(uint64(v), base10), nil 161 | case uint32: 162 | return strconv.FormatUint(uint64(v), base10), nil 163 | // HTML need to be a pointer to allow for msgpack to keep its type 164 | case HTML: 165 | return &v, nil 166 | default: 167 | return v, nil 168 | } 169 | } 170 | 171 | return pp 172 | } 173 | 174 | func PipelineProcessorAttributePluginMount(page *Page) *PipelineProcessor { 175 | cache := hashmap.New[string, *struct{}]() 176 | 177 | pp := NewPipelineProcessor(PipelineProcessorKeyAttributePluginMount) 178 | 179 | pp.BeforeAttribute = func(ctx context.Context, w io.Writer, attr Attributer) (Attributer, error) { 180 | var err error 181 | if ap, ok := attr.(AttributePluginer); ok { 182 | if set := cache.Insert(ap.GetName(), nil); set { 183 | ap.Initialize(page) 184 | 185 | err = ErrDOMInvalidated 186 | } 187 | } 188 | 189 | return attr, err 190 | } 191 | 192 | return pp 193 | } 194 | 195 | func PipelineProcessorAttributePluginMountSSR(page *Page) *PipelineProcessor { 196 | cache := hashmap.New[string, *struct{}]() 197 | 198 | pp := NewPipelineProcessor(PipelineProcessorKeyAttributePluginMount) 199 | 200 | pp.BeforeAttribute = func(ctx context.Context, w io.Writer, attr Attributer) (Attributer, error) { 201 | var err error 202 | if ap, ok := attr.(AttributePluginer); ok { 203 | if _, loaded := cache.GetOrInsert(ap.GetName(), nil); !loaded { 204 | ap.InitializeSSR(page) 205 | 206 | err = ErrDOMInvalidated 207 | } 208 | } 209 | 210 | return attr, err 211 | } 212 | 213 | return pp 214 | } 215 | -------------------------------------------------------------------------------- /pageSession.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/gorilla/websocket" 9 | "github.com/rs/zerolog" 10 | ) 11 | 12 | type PageSession struct { 13 | // Buffered channel of outbound messages. 14 | Send chan MessageWS 15 | // Buffered channel of inbound messages. 16 | Receive chan MessageWS 17 | 18 | id string 19 | connected bool 20 | connectedAt time.Time 21 | lastActive time.Time 22 | page *Page 23 | ctxInitial context.Context //nolint:containedctx // we are a router and create new contexts from this one 24 | ctxPage context.Context //nolint:containedctx // we are a router and create new contexts from this one 25 | ctxPageCancel context.CancelFunc 26 | ctxInitialCancel context.CancelFunc 27 | done chan bool 28 | wsConn *websocket.Conn 29 | logger zerolog.Logger 30 | muSess sync.RWMutex 31 | muWrite sync.RWMutex 32 | muRead sync.RWMutex 33 | } 34 | 35 | type MessageWS struct { 36 | Message []byte 37 | IsBinary bool 38 | } 39 | 40 | // readPump pumps messages from the websocket connection to the hub. 41 | // 42 | // The application runs readPump in a per-connection goroutine. The application 43 | // ensures that there is at most one reader on a connection by executing all 44 | // reads from this goroutine. 45 | func (sess *PageSession) readPump() { 46 | defer func() { 47 | sess.muSess.Lock() 48 | sess.connected = false 49 | sess.muSess.Unlock() 50 | 51 | sess.muWrite.Lock() 52 | if err := sess.wsConn.Close(); err != nil { 53 | sess.logger.Err(err).Str("sess", sess.id).Msg("ws conn close") 54 | } else { 55 | sess.logger.Debug().Str("sess", sess.id).Msg("ws conn close") 56 | } 57 | sess.muWrite.Unlock() 58 | }() 59 | 60 | sess.muWrite.Lock() 61 | 62 | // c.conn.SetReadLimit(maxMessageSize) 63 | if err := sess.wsConn.SetReadDeadline(time.Now().Add(pongWait)); err != nil { 64 | sess.logger.Err(err).Msg("read pump set read deadline") 65 | } 66 | 67 | sess.wsConn.SetPongHandler(func(string) error { 68 | sess.logger.Trace().Msg("ws pong") 69 | 70 | sess.muWrite.Lock() 71 | 72 | if err := sess.wsConn.SetReadDeadline(time.Now().Add(pongWait)); err != nil { 73 | sess.logger.Err(err).Msg("pong handler: set read deadline") 74 | } 75 | 76 | sess.muWrite.Unlock() 77 | 78 | return nil 79 | }) 80 | 81 | sess.muWrite.Unlock() 82 | 83 | for { 84 | select { 85 | case <-sess.GetContextInitial().Done(): 86 | return 87 | default: 88 | sess.muRead.Lock() 89 | 90 | mt, message, err := sess.wsConn.ReadMessage() 91 | if err != nil { 92 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 93 | sess.logger.Debug().Err(err).Msg("unexpected close error") 94 | } 95 | 96 | return 97 | } 98 | 99 | sess.muRead.Unlock() 100 | 101 | sess.muSess.Lock() 102 | sess.lastActive = time.Now() 103 | sess.muSess.Unlock() 104 | 105 | sess.Receive <- MessageWS{ 106 | Message: message, 107 | IsBinary: mt == websocket.BinaryMessage, 108 | } 109 | } 110 | } 111 | } 112 | 113 | // writePump pumps messages from the hub to the websocket connection. 114 | // 115 | // A goroutine running writePump is started for each connection. The 116 | // application ensures that there is at most one writer to a connection by 117 | // executing all writes from this goroutine. 118 | func (sess *PageSession) writePump() { 119 | ticker := time.NewTicker(pingPeriod) 120 | 121 | for { 122 | select { 123 | case <-sess.GetContextInitial().Done(): 124 | ticker.Stop() 125 | 126 | return 127 | case message, ok := <-sess.Send: 128 | sess.muWrite.Lock() 129 | 130 | if err := sess.wsConn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { 131 | sess.logger.Err(err).Msg("write pump: message set write deadline") 132 | } 133 | 134 | sess.muWrite.Unlock() 135 | 136 | if !ok { 137 | // Send channel closed. 138 | sess.muWrite.Lock() 139 | 140 | if err := sess.wsConn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil { 141 | sess.logger.Err(err).Msg("write pump: write close message") 142 | } 143 | 144 | sess.muWrite.Unlock() 145 | 146 | return 147 | } 148 | 149 | mt := websocket.TextMessage 150 | if message.IsBinary { 151 | mt = websocket.BinaryMessage 152 | } 153 | 154 | sess.muWrite.Lock() 155 | 156 | w, err := sess.wsConn.NextWriter(mt) 157 | if err != nil { 158 | sess.logger.Err(err).Msg("write pump: create writer") 159 | 160 | sess.muWrite.Unlock() 161 | 162 | continue 163 | } 164 | 165 | if _, err := w.Write(message.Message); err != nil { 166 | sess.logger.Err(err).Msg("write pump: write first message") 167 | } 168 | 169 | if err := w.Close(); err != nil { 170 | sess.logger.Err(err).Msg("write pump: close write") 171 | } 172 | 173 | sess.muWrite.Unlock() 174 | 175 | case <-ticker.C: 176 | sess.logger.Trace().Msg("ws ping") 177 | 178 | sess.muWrite.Lock() 179 | 180 | if err := sess.wsConn.SetWriteDeadline(time.Now().Add(writeWait)); err != nil { 181 | sess.logger.Err(err).Msg("write pump: ping tick: set write deadline") 182 | } 183 | 184 | if err := sess.wsConn.WriteMessage(websocket.PingMessage, nil); err != nil { 185 | sess.logger.Err(err).Msg("write pump: ping tick: write write message") 186 | } 187 | 188 | sess.muWrite.Unlock() 189 | } 190 | } 191 | } 192 | 193 | func (sess *PageSession) GetPage() *Page { 194 | sess.muSess.RLock() 195 | defer sess.muSess.RUnlock() 196 | 197 | return sess.page 198 | } 199 | 200 | func (sess *PageSession) SetPage(page *Page) { 201 | sess.muSess.Lock() 202 | sess.page = page 203 | sess.muSess.Unlock() 204 | } 205 | 206 | func (sess *PageSession) GetID() string { 207 | sess.muSess.RLock() 208 | defer sess.muSess.RUnlock() 209 | 210 | return sess.id 211 | } 212 | 213 | func (sess *PageSession) GetContextInitial() context.Context { 214 | sess.muSess.RLock() 215 | defer sess.muSess.RUnlock() 216 | 217 | return sess.ctxInitial 218 | } 219 | 220 | func (sess *PageSession) GetContextPage() context.Context { 221 | sess.muSess.RLock() 222 | defer sess.muSess.RUnlock() 223 | 224 | return sess.ctxPage 225 | } 226 | 227 | func (sess *PageSession) GetPageContextCancel() context.CancelFunc { 228 | sess.muSess.RLock() 229 | defer sess.muSess.RUnlock() 230 | 231 | return sess.ctxPageCancel 232 | } 233 | 234 | func (sess *PageSession) GetInitialContextCancel() context.CancelFunc { 235 | sess.muSess.RLock() 236 | defer sess.muSess.RUnlock() 237 | 238 | return sess.ctxInitialCancel 239 | } 240 | 241 | func (sess *PageSession) SetContextPage(ctx context.Context) { 242 | sess.muSess.Lock() 243 | sess.ctxPage = ctx 244 | sess.muSess.Unlock() 245 | } 246 | 247 | func (sess *PageSession) SetContextCancel(cancel context.CancelFunc) { 248 | sess.muSess.Lock() 249 | sess.ctxPageCancel = cancel 250 | sess.muSess.Unlock() 251 | } 252 | 253 | func (sess *PageSession) IsConnected() bool { 254 | sess.muSess.RLock() 255 | defer sess.muSess.RUnlock() 256 | 257 | return sess.connected 258 | } 259 | 260 | func (sess *PageSession) SetConnected(connected bool) { 261 | sess.muSess.Lock() 262 | sess.connected = connected 263 | sess.muSess.Unlock() 264 | } 265 | -------------------------------------------------------------------------------- /renderer.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "fmt" 5 | "html" 6 | "io" 7 | "strconv" 8 | 9 | "github.com/rs/zerolog" 10 | ) 11 | 12 | func NewRenderer() *Renderer { 13 | return &Renderer{ 14 | log: zerolog.Nop(), 15 | } 16 | } 17 | 18 | type Renderer struct { 19 | log zerolog.Logger 20 | } 21 | 22 | func (r *Renderer) SetLogger(logger zerolog.Logger) { 23 | r.log = logger 24 | } 25 | 26 | // HTML renders items that can be render to valid HTML nodes 27 | func (r *Renderer) HTML(w io.Writer, el any) error { 28 | switch v := el.(type) { 29 | case nil: 30 | return nil 31 | case *string: 32 | if v != nil { 33 | if err := r.text(*v, w); err != nil { 34 | return err 35 | } 36 | } 37 | case string: 38 | if err := r.text(v, w); err != nil { 39 | return err 40 | } 41 | case int: 42 | if _, err := w.Write([]byte(strconv.Itoa(v))); err != nil { 43 | return fmt.Errorf("html write: %w", err) 44 | } 45 | case int8: 46 | if _, err := w.Write([]byte(strconv.Itoa(int(v)))); err != nil { 47 | return fmt.Errorf("html write: %w", err) 48 | } 49 | case int16: 50 | if _, err := w.Write([]byte(strconv.Itoa(int(v)))); err != nil { 51 | return fmt.Errorf("html write: %w", err) 52 | } 53 | case int32: 54 | if _, err := w.Write([]byte(strconv.Itoa(int(v)))); err != nil { 55 | return fmt.Errorf("html write: %w", err) 56 | } 57 | case int64: 58 | if _, err := w.Write([]byte(strconv.FormatInt(v, base10))); err != nil { 59 | return fmt.Errorf("html write: %w", err) 60 | } 61 | case uint: 62 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(v), base10))); err != nil { 63 | return fmt.Errorf("html write: %w", err) 64 | } 65 | case uint8: 66 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(v), base10))); err != nil { 67 | return fmt.Errorf("html write: %w", err) 68 | } 69 | case uint16: 70 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(v), base10))); err != nil { 71 | return fmt.Errorf("html write: %w", err) 72 | } 73 | case uint32: 74 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(v), base10))); err != nil { 75 | return fmt.Errorf("html write: %w", err) 76 | } 77 | case uint64: 78 | if _, err := w.Write([]byte(strconv.FormatUint(v, base10))); err != nil { 79 | return fmt.Errorf("html write: %w", err) 80 | } 81 | case float32: 82 | if _, err := w.Write([]byte(strconv.FormatFloat(float64(v), 'f', -1, bit32))); err != nil { 83 | return fmt.Errorf("html write: %w", err) 84 | } 85 | case float64: 86 | if _, err := w.Write([]byte(strconv.FormatFloat(v, 'f', -1, bit64))); err != nil { 87 | return fmt.Errorf("html write: %w", err) 88 | } 89 | case *int: 90 | if v != nil { 91 | if _, err := w.Write([]byte(strconv.Itoa(*v))); err != nil { 92 | return fmt.Errorf("html write: %w", err) 93 | } 94 | } 95 | case *int8: 96 | if v != nil { 97 | if _, err := w.Write([]byte(strconv.Itoa(int(*v)))); err != nil { 98 | return fmt.Errorf("html write: %w", err) 99 | } 100 | } 101 | case *int16: 102 | if v != nil { 103 | if _, err := w.Write([]byte(strconv.Itoa(int(*v)))); err != nil { 104 | return fmt.Errorf("html write: %w", err) 105 | } 106 | } 107 | case *int32: 108 | if v != nil { 109 | if _, err := w.Write([]byte(strconv.Itoa(int(*v)))); err != nil { 110 | return fmt.Errorf("html write: %w", err) 111 | } 112 | } 113 | case *int64: 114 | if v != nil { 115 | if _, err := w.Write([]byte(strconv.FormatInt(*v, base10))); err != nil { 116 | return fmt.Errorf("html write: %w", err) 117 | } 118 | } 119 | case *uint: 120 | if v != nil { 121 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(*v), base10))); err != nil { 122 | return fmt.Errorf("html write: %w", err) 123 | } 124 | } 125 | case *uint8: 126 | if v != nil { 127 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(*v), base10))); err != nil { 128 | return fmt.Errorf("html write: %w", err) 129 | } 130 | } 131 | case *uint16: 132 | if v != nil { 133 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(*v), base10))); err != nil { 134 | return fmt.Errorf("html write: %w", err) 135 | } 136 | } 137 | case *uint32: 138 | if v != nil { 139 | if _, err := w.Write([]byte(strconv.FormatUint(uint64(*v), base10))); err != nil { 140 | return fmt.Errorf("html write: %w", err) 141 | } 142 | } 143 | case *uint64: 144 | if v != nil { 145 | if _, err := w.Write([]byte(strconv.FormatUint(*v, base10))); err != nil { 146 | return fmt.Errorf("html write: %w", err) 147 | } 148 | } 149 | case *float32: 150 | if v != nil { 151 | if _, err := w.Write([]byte(strconv.FormatFloat(float64(*v), 'f', -1, bit32))); err != nil { 152 | return fmt.Errorf("html write: %w", err) 153 | } 154 | } 155 | case *float64: 156 | if v != nil { 157 | if _, err := w.Write([]byte(strconv.FormatFloat(*v, 'f', -1, bit64))); err != nil { 158 | return fmt.Errorf("html write: %w", err) 159 | } 160 | } 161 | case *HTML: 162 | if v == nil { 163 | return nil 164 | } 165 | 166 | if _, err := w.Write([]byte(*v)); err != nil { 167 | return err 168 | } 169 | case HTML: 170 | if _, err := w.Write([]byte(v)); err != nil { 171 | return err 172 | } 173 | case Tagger: 174 | if v != nil { 175 | if err := r.tag(v, w); err != nil { 176 | return err 177 | } 178 | } 179 | // I don't think this is possible anymore 180 | case []any: 181 | if err := r.HTML(w, G(v...)); err != nil { 182 | return err 183 | } 184 | case *NodeGroup: 185 | g := v.Get() 186 | for i := 0; i < len(g); i++ { 187 | if err := r.HTML(w, g[i]); err != nil { 188 | return err 189 | } 190 | } 191 | default: 192 | return ErrRenderElement 193 | } 194 | 195 | return nil 196 | } 197 | 198 | // Attribute renders an Attribute to it's HTML string representation 199 | // While it's possible to have HTML attributes without values it simplifies things if we always have a value 200 | func (r *Renderer) Attribute(attrs []Attributer, w io.Writer) error { 201 | for i := 0; i < len(attrs); i++ { 202 | attr := attrs[i] 203 | if attr.IsNoEscapeString() { 204 | if _, err := w.Write([]byte(fmt.Sprintf(` %s="%s"`, attr.GetName(), attr.GetValue()))); err != nil { 205 | return fmt.Errorf("write: %w", err) 206 | } 207 | } else { 208 | if _, err := w.Write([]byte(fmt.Sprintf(` %s="%s"`, attr.GetName(), html.EscapeString(attr.GetValue())))); err != nil { 209 | return fmt.Errorf("write: %w", err) 210 | } 211 | } 212 | } 213 | 214 | return nil 215 | } 216 | 217 | func (r *Renderer) text(text string, w io.Writer) error { 218 | if text == "" { 219 | return nil 220 | } 221 | 222 | if _, err := w.Write([]byte(html.EscapeString(text))); err != nil { 223 | return fmt.Errorf("write to writer: %w", err) 224 | } 225 | 226 | return nil 227 | } 228 | 229 | func (r *Renderer) tag(tag Tagger, w io.Writer) error { 230 | if _, err := w.Write([]byte("<" + tag.GetName())); err != nil { 231 | return fmt.Errorf("write: %w", err) 232 | } 233 | 234 | if err := r.Attribute(tag.GetAttributes(), w); err != nil { 235 | return fmt.Errorf("render attributes: %w", err) 236 | } 237 | 238 | if _, err := w.Write([]byte(">")); err != nil { 239 | return fmt.Errorf("write: %w", err) 240 | } 241 | 242 | if tag.IsVoid() { 243 | return nil 244 | } 245 | 246 | if err := r.HTML(w, tag.GetNodes()); err != nil { 247 | return fmt.Errorf("render nodes for: %s: %w", tag.GetName(), err) 248 | } 249 | 250 | if _, err := w.Write([]byte("")); err != nil { 251 | return fmt.Errorf("write: %w", err) 252 | } 253 | 254 | return nil 255 | } 256 | -------------------------------------------------------------------------------- /hlive.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/vmihailenco/msgpack/v5" 9 | ) 10 | 11 | // HTML must always have a single root element, as we count it as 1 node in the tree but the browser will not if you 12 | // have multiple root elements 13 | type HTML string 14 | 15 | func (e *HTML) MarshalMsgpack() ([]byte, error) { 16 | return []byte(*e), nil 17 | } 18 | 19 | func (e *HTML) UnmarshalMsgpack(b []byte) error { 20 | *e = HTML(b) 21 | 22 | return nil 23 | } 24 | 25 | // IsElement returns true is the pass value is a valid Element. 26 | // 27 | // An Element is anything that cna be rendered at HTML. 28 | func IsElement(el any) bool { 29 | if IsNonNodeElement(el) { 30 | return true 31 | } 32 | 33 | return IsNode(el) 34 | } 35 | 36 | func IsNonNodeElement(el any) bool { 37 | switch el.(type) { 38 | case []Attributer, []*Attribute, *Attribute, Attributer, Attrs, AttrsLockBox, AttrsOff, 39 | ClassBool, Style, ClassList, ClassListOff, Class, ClassOff, 40 | *EventBinding: 41 | return true 42 | default: 43 | return false 44 | } 45 | } 46 | 47 | type NodeBoxer interface { 48 | GetNode() any 49 | } 50 | 51 | type NodeBox[V any] struct { 52 | *LockBox[V] 53 | } 54 | 55 | func (b NodeBox[V]) GetNode() any { 56 | return b.Get() 57 | } 58 | 59 | func Box[V any](node V) *NodeBox[V] { 60 | if !IsNode(node) { 61 | LoggerDev.Error(). 62 | Str("callers", CallerStackStr()). 63 | Str("node", fmt.Sprintf("%#v", node)). 64 | Msg("invalid node") 65 | } 66 | 67 | return &NodeBox[V]{NewLockBox(node)} 68 | } 69 | 70 | type LockBox[V any] struct { 71 | mu sync.RWMutex 72 | val V 73 | } 74 | 75 | func (b *LockBox[V]) Set(val V) { 76 | b.mu.Lock() 77 | defer b.mu.Unlock() 78 | 79 | b.val = val 80 | } 81 | 82 | func (b *LockBox[V]) Get() V { 83 | b.mu.RLock() 84 | defer b.mu.RUnlock() 85 | 86 | return b.val 87 | } 88 | 89 | type LockBoxer interface { 90 | GetLockedAny() any 91 | } 92 | 93 | func (b *LockBox[V]) GetLockedAny() any { 94 | return b.Get() 95 | } 96 | 97 | func (b *LockBox[V]) Lock(f func(val V) V) { 98 | b.mu.Lock() 99 | defer b.mu.Unlock() 100 | 101 | b.val = f(b.val) 102 | } 103 | 104 | func NewLockBox[V any](val V) *LockBox[V] { 105 | return &LockBox[V]{val: val} 106 | } 107 | 108 | // IsNode returns true is the pass value is a valid Node. 109 | // 110 | // A Node is a value that could be rendered as HTML by itself. An int for example can be converted to a string which is 111 | // valid HTML. An attribute would not be valid and doesn't make sense to cast to a string. 112 | func IsNode(node any) bool { 113 | switch node.(type) { 114 | // TODO: Need *HTML for encoding, maybe new read only Tag will help 115 | case nil, string, HTML, *HTML, Tagger, 116 | []any, *NodeGroup, []*Component, []*Tag, []Componenter, []Tagger, []UniqueTagger, 117 | int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, 118 | NodeBoxer, LockBoxer: 119 | return true 120 | default: 121 | return false 122 | } 123 | } 124 | 125 | // G is shorthand for Group. 126 | // 127 | // Group zero or more Nodes together. 128 | func G(nodes ...any) *NodeGroup { 129 | return Group(nodes...) 130 | } 131 | 132 | // Group zero or more Nodes together. 133 | func Group(nodes ...any) *NodeGroup { 134 | g := &NodeGroup{} 135 | 136 | g.Add(nodes...) 137 | 138 | return g 139 | } 140 | 141 | // NodeGroup is a Group of Nodes 142 | type NodeGroup struct { 143 | group []any 144 | mu sync.RWMutex 145 | } 146 | 147 | func (g *NodeGroup) MarshalMsgpack() ([]byte, error) { 148 | g.mu.RLock() 149 | defer g.mu.RUnlock() 150 | 151 | return msgpack.Marshal(g.group) //nolint:wrapcheck 152 | } 153 | 154 | func (g *NodeGroup) UnmarshalMsgpack(b []byte) error { 155 | g.mu.Lock() 156 | defer g.mu.Unlock() 157 | 158 | return msgpack.Unmarshal(b, &g.group) //nolint:wrapcheck 159 | } 160 | 161 | func (g *NodeGroup) Add(nodes ...any) { 162 | if g == nil { 163 | LoggerDev.Error().Str("callers", CallerStackStr()).Msg("nil call") 164 | 165 | return 166 | } 167 | 168 | g.mu.Lock() 169 | defer g.mu.Unlock() 170 | 171 | for i := 0; i < len(nodes); i++ { 172 | if !IsNode(nodes[i]) { 173 | LoggerDev.Error(). 174 | Str("callers", CallerStackStr()). 175 | Str("node", fmt.Sprintf("%#v", nodes[i])). 176 | Msg("invalid node") 177 | 178 | continue 179 | } 180 | 181 | g.group = append(g.group, nodes[i]) 182 | } 183 | } 184 | 185 | // Get returns all nodes, dereferences any valid pointers 186 | func (g *NodeGroup) Get() []any { 187 | if g == nil { 188 | LoggerDev.Error().Str("callers", CallerStackStr()).Msg("nil call") 189 | 190 | return nil 191 | } 192 | 193 | g.mu.RLock() 194 | defer g.mu.RUnlock() 195 | 196 | var newGroup []any 197 | 198 | for i := 0; i < len(g.group); i++ { 199 | node := g.group[i] 200 | if node == nil { 201 | continue 202 | } 203 | 204 | switch v := node.(type) { 205 | case NodeBoxer: 206 | newGroup = append(newGroup, v.GetNode()) 207 | case LockBoxer: 208 | newGroup = append(newGroup, v.GetLockedAny()) 209 | default: 210 | newGroup = append(newGroup, node) 211 | } 212 | } 213 | 214 | return newGroup 215 | } 216 | 217 | // E is shorthand for Elements. 218 | // 219 | // Groups zero or more Element values. 220 | func E(elements ...any) *ElementGroup { 221 | return Elements(elements...) 222 | } 223 | 224 | // Elements groups zero or more Element values. 225 | func Elements(elements ...any) *ElementGroup { 226 | g := &ElementGroup{} 227 | 228 | g.Add(elements...) 229 | 230 | return g 231 | } 232 | 233 | // ElementGroup is a Group of Elements 234 | type ElementGroup struct { 235 | group []any 236 | mu sync.RWMutex 237 | } 238 | 239 | func (g *ElementGroup) Add(elements ...any) { 240 | if g == nil { 241 | LoggerDev.Error().Str("callers", CallerStackStr()).Msg("nil call") 242 | 243 | return 244 | } 245 | 246 | g.mu.Lock() 247 | defer g.mu.Unlock() 248 | 249 | for i := 0; i < len(elements); i++ { 250 | if !IsElement(elements[i]) { 251 | LoggerDev.Error(). 252 | Str("callers", CallerStackStr()). 253 | Str("element", fmt.Sprintf("%#v", elements[i])). 254 | Msg("invalid element") 255 | 256 | continue 257 | } 258 | 259 | g.group = append(g.group, elements[i]) 260 | } 261 | } 262 | 263 | func (g *ElementGroup) Get() []any { 264 | if g == nil { 265 | LoggerDev.Error().Str("callers", CallerStackStr()).Msg("nil call") 266 | 267 | return nil 268 | } 269 | 270 | g.mu.RLock() 271 | defer g.mu.RUnlock() 272 | 273 | var newGroup []any 274 | 275 | for i := 0; i < len(g.group); i++ { 276 | node := g.group[i] 277 | if node == nil { 278 | continue 279 | } 280 | 281 | switch v := node.(type) { 282 | case NodeBoxer: 283 | newGroup = append(newGroup, v.GetNode()) 284 | case LockBoxer: 285 | newGroup = append(newGroup, v.GetLockedAny()) 286 | default: 287 | newGroup = append(newGroup, node) 288 | } 289 | } 290 | 291 | return newGroup 292 | } 293 | 294 | // Render will trigger a WebSocket render for the current page 295 | func Render(ctx context.Context) { 296 | render, ok := ctx.Value(CtxRender).(func(context.Context)) 297 | if !ok { 298 | LoggerDev.Error().Str("callers", CallerStackStr()).Msg("render not found in context") 299 | 300 | return 301 | } 302 | 303 | render(ctx) 304 | } 305 | 306 | // RenderComponent will trigger a WebSocket render for the current page from the passed Componenter down only 307 | func RenderComponent(ctx context.Context, comp Componenter) { 308 | render, ok := ctx.Value(CtxRenderComponent).(func(context.Context, Componenter)) 309 | if !ok { 310 | LoggerDev.Error().Str("callers", CallerStackStr()).Msg("component render not found in context") 311 | 312 | return 313 | } 314 | 315 | render(ctx, comp) 316 | } 317 | -------------------------------------------------------------------------------- /attribute_test.go: -------------------------------------------------------------------------------- 1 | package hlive_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | l "github.com/SamHennessy/hlive" 8 | "github.com/go-test/deep" 9 | ) 10 | 11 | func TestAttribute_GetValueSetValue(t *testing.T) { 12 | t.Parallel() 13 | 14 | a := l.NewAttribute("foo", "") 15 | 16 | if diff := deep.Equal("", a.GetValue()); diff != nil { 17 | t.Error(diff) 18 | } 19 | 20 | a.SetValue("bar") 21 | 22 | if diff := deep.Equal("bar", a.GetValue()); diff != nil { 23 | t.Error(diff) 24 | } 25 | } 26 | 27 | func TestAttribute_SetValueLock(t *testing.T) { 28 | t.Parallel() 29 | 30 | value := l.NewLockBox("") 31 | 32 | a := l.NewAttributeLockBox("foo", value) 33 | 34 | value.Set("bar") 35 | 36 | if diff := deep.Equal("bar", a.GetValue()); diff != nil { 37 | t.Error(diff) 38 | } 39 | 40 | value.Set("fizz") 41 | 42 | if diff := deep.Equal("fizz", a.GetValue()); diff != nil { 43 | t.Error(diff) 44 | } 45 | } 46 | 47 | func TestAttribute_TagAddAttributesGetAttributes(t *testing.T) { 48 | t.Parallel() 49 | 50 | div := l.T("div") 51 | 52 | a := l.NewAttribute("foo", "bar") 53 | b := l.NewAttribute("biz", "baz") 54 | 55 | div.AddAttributes(a, b) 56 | 57 | if diff := deep.Equal([]l.Attributer{a, b}, div.GetAttributes()); diff != nil { 58 | t.Error(diff) 59 | } 60 | 61 | div.RemoveAttributes("foo") 62 | 63 | if diff := deep.Equal([]l.Attributer{b}, div.GetAttributes()); diff != nil { 64 | t.Error(diff) 65 | } 66 | } 67 | 68 | func TestAttribute_TagAttrs(t *testing.T) { 69 | t.Parallel() 70 | 71 | div := l.T("div", l.Attrs{"foo": "bar"}, l.Attrs{"biz": "baz"}) 72 | 73 | a := l.NewAttribute("foo", "bar") 74 | b := l.NewAttribute("biz", "baz") 75 | 76 | if diff := deep.Equal(a.GetValue(), div.GetAttributeValue("foo")); diff != nil { 77 | t.Error(diff) 78 | } 79 | 80 | if diff := deep.Equal(b.GetValue(), div.GetAttributeValue("biz")); diff != nil { 81 | t.Error(diff) 82 | } 83 | } 84 | 85 | func TestAttribute_TagRemoveAttribute(t *testing.T) { 86 | t.Parallel() 87 | 88 | div := l.T("div", l.NewAttribute("foo", "bar")) 89 | 90 | if diff := deep.Equal(1, len(div.GetAttributes())); diff != nil { 91 | t.Error(diff) 92 | } 93 | 94 | div.RemoveAttributes("foo") 95 | 96 | if diff := deep.Equal(0, len(div.GetAttributes())); diff != nil { 97 | t.Error(diff) 98 | } 99 | } 100 | 101 | func TestAttribute_TagNewAttributeRemove(t *testing.T) { 102 | t.Parallel() 103 | 104 | div := l.T("div", l.NewAttribute("foo", "bar")) 105 | 106 | if diff := deep.Equal(1, len(div.GetAttributes())); diff != nil { 107 | t.Error(diff) 108 | } 109 | 110 | div.Add(l.AttrsOff{"foo"}) 111 | 112 | if diff := deep.Equal(0, len(div.GetAttributes())); diff != nil { 113 | t.Error(diff) 114 | } 115 | } 116 | 117 | func TestAttribute_TagAttrsRemove(t *testing.T) { 118 | t.Parallel() 119 | 120 | div := l.T("div", l.Attrs{"foo": "bar"}) 121 | 122 | if diff := deep.Equal(1, len(div.GetAttributes())); diff != nil { 123 | t.Error(diff) 124 | } 125 | 126 | div.Add(l.AttrsOff{"foo"}) 127 | 128 | if diff := deep.Equal(0, len(div.GetAttributes())); diff != nil { 129 | t.Error(diff) 130 | } 131 | } 132 | 133 | func TestAttribute_TagAttrsReferenceValue(t *testing.T) { 134 | t.Parallel() 135 | 136 | attrVal := l.NewLockBox("bar") 137 | 138 | div := l.T("div", l.AttrsLockBox{"foo": attrVal}) 139 | 140 | if diff := deep.Equal("bar", div.GetAttributeValue("foo")); diff != nil { 141 | t.Error(diff) 142 | } 143 | 144 | attrVal.Set("baz") 145 | 146 | if diff := deep.Equal("baz", div.GetAttributeValue("foo")); diff != nil { 147 | t.Error(diff) 148 | } 149 | } 150 | 151 | func TestAttribute_TagAttrsUpdateValue(t *testing.T) { 152 | t.Parallel() 153 | 154 | div := l.T("div", l.Attrs{"foo": "bar"}) 155 | 156 | if diff := deep.Equal("bar", div.GetAttributeValue("foo")); diff != nil { 157 | t.Error(diff) 158 | } 159 | 160 | div.Add(l.Attrs{"foo": "baz"}) 161 | 162 | if diff := deep.Equal("baz", div.GetAttributeValue("foo")); diff != nil { 163 | t.Error(diff) 164 | } 165 | } 166 | 167 | func TestAttribute_TagNewAttributeUpdateValue(t *testing.T) { 168 | t.Parallel() 169 | 170 | div := l.T("div", l.NewAttribute("foo", "bar")) 171 | 172 | if diff := deep.Equal("bar", div.GetAttributeValue("foo")); diff != nil { 173 | t.Error(diff) 174 | } 175 | 176 | div.Add(l.NewAttribute("foo", "baz")) 177 | 178 | if diff := deep.Equal("baz", div.GetAttributeValue("foo")); diff != nil { 179 | t.Error(diff) 180 | } 181 | } 182 | 183 | func TestAttribute_CSS(t *testing.T) { 184 | t.Parallel() 185 | 186 | div := l.T("div", l.ClassBool{"foo": true}) 187 | 188 | if diff := deep.Equal("foo", div.GetAttributeValue("class")); diff != nil { 189 | t.Error(diff) 190 | } 191 | 192 | div.Add("div", l.ClassBool{"bar": true}) 193 | 194 | if diff := deep.Equal("foo bar", div.GetAttributeValue("class")); diff != nil { 195 | t.Error(diff) 196 | } 197 | 198 | div.Add("div", l.ClassBool{"foo": false}) 199 | 200 | if diff := deep.Equal("bar", div.GetAttributeValue("class")); diff != nil { 201 | t.Error(diff) 202 | } 203 | 204 | div.Add("div", l.ClassBool{"foo": true}) 205 | 206 | if diff := deep.Equal("bar foo", div.GetAttributeValue("class")); diff != nil { 207 | t.Error(diff) 208 | } 209 | } 210 | 211 | func TestAttribute_CSSMultiUnordered(t *testing.T) { 212 | t.Parallel() 213 | 214 | div := l.T("div", l.ClassBool{"foo": true, "bar": true, "fizz": true}) 215 | 216 | if !strings.Contains(div.GetAttributeValue("class"), "foo") { 217 | t.Error("foo not found") 218 | } 219 | 220 | if !strings.Contains(div.GetAttributeValue("class"), "bar") { 221 | t.Error("bar not found") 222 | } 223 | 224 | if !strings.Contains(div.GetAttributeValue("class"), "fizz") { 225 | t.Error("fizz not found") 226 | } 227 | 228 | div.Add(l.ClassBool{"bar": false}) 229 | 230 | if strings.Contains(div.GetAttributeValue("class"), "bar") { 231 | t.Error("bar found") 232 | } 233 | } 234 | 235 | func TestAttribute_CSSMultiOrdered(t *testing.T) { 236 | t.Parallel() 237 | 238 | div := l.T("div", l.ClassBool{"foo": true}, l.ClassBool{"bar": true}, l.ClassBool{"fizz": true}) 239 | 240 | if diff := deep.Equal("foo bar fizz", div.GetAttributeValue("class")); diff != nil { 241 | t.Error(diff) 242 | } 243 | 244 | div.Add(l.ClassBool{"bar": false}) 245 | 246 | if diff := deep.Equal("foo fizz", div.GetAttributeValue("class")); diff != nil { 247 | t.Error(diff) 248 | } 249 | } 250 | 251 | func TestAttribute_Style(t *testing.T) { 252 | t.Parallel() 253 | 254 | div := l.T("div", l.Style{"foo": "bar"}) 255 | 256 | if diff := deep.Equal("foo:bar;", div.GetAttributeValue("style")); diff != nil { 257 | t.Error(diff) 258 | } 259 | 260 | div.Add("div", l.StyleOff{"foo"}) 261 | 262 | if diff := deep.Equal("", div.GetAttributeValue("style")); diff != nil { 263 | t.Error(diff) 264 | } 265 | } 266 | 267 | func TestAttribute_StyleOverride(t *testing.T) { 268 | t.Parallel() 269 | 270 | div := l.T("div", l.Style{"foo": "bar"}) 271 | 272 | if diff := deep.Equal("foo:bar;", div.GetAttributeValue("style")); diff != nil { 273 | t.Error(diff) 274 | } 275 | 276 | div.Add("div", l.Style{"foo": "fizz"}) 277 | 278 | if diff := deep.Equal("foo:fizz;", div.GetAttributeValue("style")); diff != nil { 279 | t.Error(diff) 280 | } 281 | } 282 | 283 | func TestAttribute_StyleMultiUnordered(t *testing.T) { 284 | t.Parallel() 285 | 286 | div := l.T("div", l.Style{"foo": "a", "bar": "b", "fizz": "c"}) 287 | 288 | if !strings.Contains(div.GetAttributeValue("style"), "foo:a;") { 289 | t.Error("foo:a; not found") 290 | } 291 | 292 | if !strings.Contains(div.GetAttributeValue("style"), "bar:b;") { 293 | t.Error("bar:a; not found") 294 | } 295 | 296 | if !strings.Contains(div.GetAttributeValue("style"), "fizz:c;") { 297 | t.Error("fizz:c; not found") 298 | } 299 | 300 | div.Add(l.StyleOff{"bar"}) 301 | 302 | if strings.Contains(div.GetAttributeValue("style"), "bar:b;") { 303 | t.Error("bar:a; found") 304 | } 305 | } 306 | 307 | func TestAttribute_StyleMultiOrdered(t *testing.T) { 308 | t.Parallel() 309 | 310 | div := l.T("div", l.Style{"foo": "a"}, l.Style{"bar": "b"}, l.Style{"fizz": "c"}) 311 | 312 | if diff := deep.Equal("foo:a;bar:b;fizz:c;", div.GetAttributeValue("style")); diff != nil { 313 | t.Error(diff) 314 | } 315 | 316 | div.Add(l.Style{"bar": "z"}) 317 | 318 | if diff := deep.Equal("foo:a;bar:z;fizz:c;", div.GetAttributeValue("style")); diff != nil { 319 | t.Error(diff) 320 | } 321 | 322 | div.Add(l.StyleOff{"bar"}) 323 | 324 | if diff := deep.Equal("foo:a;fizz:c;", div.GetAttributeValue("style")); diff != nil { 325 | t.Error(diff) 326 | } 327 | 328 | div.Add(l.Style{"bar": "x"}) 329 | 330 | if diff := deep.Equal("foo:a;fizz:c;bar:x;", div.GetAttributeValue("style")); diff != nil { 331 | t.Error(diff) 332 | } 333 | } 334 | 335 | func TestAttribute_Clone(t *testing.T) { 336 | t.Parallel() 337 | 338 | a := l.NewAttribute("foo", "bar") 339 | b := a.Clone() 340 | 341 | if diff := deep.Equal(a, b); diff != nil { 342 | t.Error(diff) 343 | } 344 | 345 | if a == b { 346 | t.Error("attributes are the same") 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /_example/initial_sync/initial_sync.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "strings" 8 | 9 | l "github.com/SamHennessy/hlive" 10 | ) 11 | 12 | func main() { 13 | http.Handle("/", l.NewPageServer(home)) 14 | 15 | log.Println("INFO: listing :3000") 16 | 17 | if err := http.ListenAndServe(":3000", nil); err != nil { 18 | log.Println("ERRO: http listen and serve: ", err) 19 | } 20 | } 21 | 22 | const pageStyle l.HTML = ` 23 | .box { 24 | display: grid; 25 | grid-template-columns: 1fr 1fr 1fr; 26 | gap: 0.5em; 27 | } 28 | 29 | input, select { 30 | width: 100%; 31 | } 32 | ` 33 | 34 | func home() *l.Page { 35 | page := l.NewPage() 36 | page.DOM().Title().Add("Form Data Initial Sync Example") 37 | page.DOM().Head().Add(l.T("link", 38 | l.Attrs{"rel": "stylesheet", "href": "https://cdn.simplecss.org/simple.min.css"})) 39 | page.DOM().Head().Add(l.T("style", pageStyle)) 40 | 41 | var ( 42 | formValsSync [9]*l.NodeBox[string] 43 | formValsNoSync [len(formValsSync)]*l.NodeBox[string] 44 | ) 45 | 46 | for i := 0; i < len(formValsSync); i++ { 47 | formValsSync[i] = l.Box("") 48 | formValsNoSync[i] = l.Box("") 49 | } 50 | 51 | page.DOM().Body().Add( 52 | l.T("header", 53 | l.T("h1", "Form Data Initial Sync"), 54 | l.T("p", "Some browsers, life FireFox, don't clear form field data after a page reload. "+ 55 | "HLive will send this data to relevant inputs when this happens."), 56 | ), 57 | l.T("main", 58 | l.T("p", "To test, using FireFox, change the fields below then reload."), 59 | l.T("h2", "Form"), 60 | l.T("form", l.Class("box"), 61 | // 62 | // Text 63 | // 64 | l.T("div", 65 | l.T("label", "Text"), 66 | l.T("br"), 67 | l.C("input", l.Attrs{"type": "text"}, 68 | l.On("input", func(_ context.Context, e l.Event) { 69 | if !e.IsInitial { 70 | formValsNoSync[0].Set(e.Value) 71 | } 72 | 73 | formValsSync[0].Set(e.Value) 74 | }), 75 | ), 76 | ), 77 | // 78 | // Password 79 | // 80 | l.T("div", 81 | l.T("label", "Password"), 82 | l.T("br"), 83 | l.C("input", l.Attrs{"type": "password"}, 84 | l.On("input", func(_ context.Context, e l.Event) { 85 | if !e.IsInitial { 86 | formValsNoSync[1].Set(e.Value) 87 | } 88 | 89 | formValsSync[1].Set(e.Value) 90 | })), 91 | ), 92 | // 93 | // Range 94 | // 95 | l.T("div", 96 | l.T("label", "Range"), 97 | l.T("br"), 98 | l.C("input", l.Attrs{"type": "range", "min": "0", "max": "1000"}, 99 | l.On("input", func(_ context.Context, e l.Event) { 100 | if !e.IsInitial { 101 | formValsNoSync[2].Set(e.Value) 102 | } 103 | 104 | formValsSync[2].Set(e.Value) 105 | }), 106 | ), 107 | ), 108 | // 109 | // Multi select 110 | // 111 | l.T("div", 112 | l.T("label", "Multi Select"), 113 | l.T("br"), 114 | l.C("select", l.Attrs{"multiple": ""}, 115 | l.On("input", func(_ context.Context, e l.Event) { 116 | if !e.IsInitial { 117 | formValsNoSync[3].Set(strings.Join(e.Values, ", ")) 118 | } 119 | 120 | formValsSync[3].Set(strings.Join(e.Values, ", ")) 121 | }), 122 | l.T("option", l.Attrs{"value": "dog"}, "Dog"), 123 | l.T("option", l.Attrs{"value": "cat"}, "Cat"), 124 | l.T("option", l.Attrs{"value": "bird"}, "Bird"), 125 | l.T("option", "Fox"), 126 | ), 127 | l.T("br"), 128 | l.T("small", "Click + Ctl/Cmd to multi select"), 129 | ), 130 | // 131 | // Radio 132 | // 133 | l.T("div", 134 | l.T("label", "Radio"), 135 | l.T("br"), 136 | l.T("label", l.Attrs{"for": "radio_1"}, 137 | l.C("input", 138 | l.Attrs{"type": "radio", "name": "radio", "value": "orange", "id": "radio_1"}, 139 | l.On("input", func(_ context.Context, e l.Event) { 140 | if !e.IsInitial { 141 | formValsNoSync[4].Set(e.Value) 142 | } 143 | 144 | formValsSync[4].Set(e.Value) 145 | }), 146 | ), 147 | " Orange"), 148 | l.T("label", l.Attrs{"for": "radio_2"}, 149 | l.C("input", 150 | l.Attrs{"type": "radio", "name": "radio", "value": "grape", "id": "radio_2"}, 151 | l.On("input", func(_ context.Context, e l.Event) { 152 | if !e.IsInitial { 153 | formValsNoSync[4].Set(e.Value) 154 | } 155 | 156 | formValsSync[4].Set(e.Value) 157 | }), 158 | ), 159 | " Grape"), 160 | l.T("label", l.Attrs{"for": "radio_3"}, 161 | l.C("input", 162 | l.Attrs{"type": "radio", "name": "radio", "value": "lemon", "id": "radio_3"}, 163 | l.On("input", func(_ context.Context, e l.Event) { 164 | if !e.IsInitial { 165 | formValsNoSync[4].Set(e.Value) 166 | } 167 | 168 | formValsSync[4].Set(e.Value) 169 | }), 170 | ), 171 | " Lemon"), 172 | l.T("label", l.Attrs{"for": "radio_4"}, 173 | l.C("input", 174 | l.Attrs{"type": "radio", "name": "radio", "value": "apple", "id": "radio_4"}, 175 | l.On("input", func(_ context.Context, e l.Event) { 176 | if !e.IsInitial { 177 | formValsNoSync[4].Set(e.Value) 178 | } 179 | 180 | formValsSync[4].Set(e.Value) 181 | }), 182 | ), 183 | " Apple"), 184 | ), 185 | // 186 | // Checkbox 187 | // 188 | l.T("div", 189 | l.T("label", "Checkbox"), 190 | l.T("br"), 191 | 192 | l.T("label", l.Attrs{"for": "checkbox_1"}, 193 | l.C("input", l.Attrs{"type": "checkbox", "value": "north", "id": "checkbox_1"}, 194 | l.On("input", func(_ context.Context, e l.Event) { 195 | formValsNoSync[5].Set("") 196 | formValsSync[5].Set("") 197 | 198 | if !e.IsInitial && e.Selected { 199 | formValsNoSync[5].Set(e.Value) 200 | } 201 | 202 | if e.Selected { 203 | formValsSync[5].Set(e.Value) 204 | } 205 | }), 206 | ), 207 | "North"), 208 | l.T("label", l.Attrs{"for": "checkbox_2"}, 209 | l.C("input", l.Attrs{"type": "checkbox", "value": "east", "id": "checkbox_2"}, 210 | l.On("input", func(_ context.Context, e l.Event) { 211 | formValsNoSync[6].Set("") 212 | formValsSync[6].Set("") 213 | 214 | if !e.IsInitial && e.Selected { 215 | formValsNoSync[6].Set(e.Value) 216 | } 217 | 218 | if e.Selected { 219 | formValsSync[6].Set(e.Value) 220 | } 221 | }), 222 | ), 223 | "East"), 224 | l.T("label", l.Attrs{"for": "checkbox_3"}, 225 | l.C("input", l.Attrs{"type": "checkbox", "value": "south", "id": "checkbox_3"}, 226 | l.On("input", func(_ context.Context, e l.Event) { 227 | formValsNoSync[7].Set("") 228 | formValsSync[7].Set("") 229 | 230 | if !e.IsInitial && e.Selected { 231 | formValsNoSync[7].Set(e.Value) 232 | } 233 | 234 | if e.Selected { 235 | formValsSync[7].Set(e.Value) 236 | } 237 | }), 238 | ), 239 | "South"), 240 | l.T("label", l.Attrs{"for": "checkbox_4"}, 241 | l.C("input", l.Attrs{"type": "checkbox", "value": "west", "id": "checkbox_4"}, 242 | l.On("input", func(_ context.Context, e l.Event) { 243 | formValsNoSync[8].Set("") 244 | formValsSync[8].Set("") 245 | 246 | if !e.IsInitial && e.Selected { 247 | formValsNoSync[8].Set(e.Value) 248 | } 249 | 250 | if e.Selected { 251 | formValsSync[8].Set(e.Value) 252 | } 253 | }), 254 | ), 255 | "West"), 256 | ), 257 | ), 258 | // 259 | // Output 260 | // 261 | l.T("h2", "Server Side Data"), 262 | l.T("table", 263 | l.T("thead", 264 | l.T("tr", 265 | l.T("th", ""), 266 | l.T("th", "Sync"), 267 | l.T("th", "No Sync"), 268 | ), 269 | ), 270 | l.T("tbody", 271 | l.T("tr", 272 | l.T("td", "Text"), 273 | l.T("td", formValsSync[0]), 274 | l.T("td", formValsNoSync[0]), 275 | ), 276 | l.T("tr", 277 | l.T("td", "Password", l.T("br"), 278 | l.T("small", "Browsers won't keep this on refresh")), 279 | l.T("td", formValsSync[1]), 280 | l.T("td", formValsNoSync[1]), 281 | ), 282 | l.T("tr", 283 | l.T("td", "Range", l.T("br"), 284 | l.T("small", "Browsers set this to the mid point by default")), 285 | l.T("td", formValsSync[2]), 286 | l.T("td", formValsNoSync[2]), 287 | ), 288 | l.T("tr", 289 | l.T("td", "Multi Select"), 290 | l.T("td", formValsSync[3]), 291 | l.T("td", formValsNoSync[3]), 292 | ), 293 | l.T("tr", 294 | l.T("td", "Radio"), 295 | l.T("td", formValsSync[4]), 296 | l.T("td", formValsNoSync[4]), 297 | ), 298 | l.T("tr", 299 | l.T("td", "Checkbox"), 300 | l.T("td", 301 | formValsSync[5], " ", formValsSync[6], " ", formValsSync[7], " ", formValsSync[8]), 302 | l.T("td", 303 | formValsNoSync[5], " ", formValsNoSync[6], " ", formValsNoSync[7], " ", formValsNoSync[8]), 304 | ), 305 | ), 306 | ), 307 | ), 308 | ) 309 | 310 | return page 311 | } 312 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= 2 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 3 | github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= 4 | github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 5 | github.com/cornelk/hashmap v1.0.8 h1:nv0AWgw02n+iDcawr5It4CjQIAcdMMKRrs10HOJYlrc= 6 | github.com/cornelk/hashmap v1.0.8/go.mod h1:RfZb7JO3RviW/rT6emczVuC/oxpdz4UsSB2LJSclR1k= 7 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= 8 | github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI= 13 | github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= 14 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= 15 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 16 | github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= 17 | github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= 18 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 19 | github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= 20 | github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= 21 | github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= 22 | github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= 23 | github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 24 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 25 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= 26 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 27 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 28 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 29 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 30 | github.com/h2non/filetype v1.1.1/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= 31 | github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= 32 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 33 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 34 | github.com/playwright-community/playwright-go v0.2000.1 h1:2JViSHpJQ/UL/PO1Gg6gXV5IcXAAsoBJ3KG9L3wKXto= 35 | github.com/playwright-community/playwright-go v0.2000.1/go.mod h1:1y9cM9b9dVHnuRWzED1KLM7FtbwTJC8ibDjI6MNqewU= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 39 | github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= 40 | github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 41 | github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc= 42 | github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc= 43 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 44 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 45 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 46 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 47 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 48 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 49 | github.com/tdewolff/minify/v2 v2.12.0 h1:ZyvMKeciyR3vzJrK/oHyBcSmpttQ/V+ah7qOqTZclaU= 50 | github.com/tdewolff/minify/v2 v2.12.0/go.mod h1:8mvf+KglD7XurfvvFZDUYvVURy6bA/r0oTvmakXMnyg= 51 | github.com/tdewolff/parse/v2 v2.6.1 h1:RIfy1erADkO90ynJWvty8VIkqqKYRzf2iLp8ObG174I= 52 | github.com/tdewolff/parse/v2 v2.6.1/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= 53 | github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= 54 | github.com/tdewolff/test v1.0.7 h1:8Vs0142DmPFW/bQeHRP3MV19m1gvndjUb1sn8yy74LM= 55 | github.com/tdewolff/test v1.0.7/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= 56 | github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125 h1:3SNcvBmEPE1YlB1JpVZouslJpI3GBNoiqW7+wb0Rz7w= 57 | github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= 58 | github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= 59 | github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 60 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 61 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 62 | github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 63 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 64 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 65 | golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 66 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 67 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 68 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 69 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 70 | golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 71 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 72 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 73 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 74 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 75 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 76 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 78 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= 81 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 83 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 84 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 85 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 86 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 87 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 88 | golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= 89 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 90 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 92 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 93 | gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= 94 | gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= 95 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 96 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 97 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 98 | -------------------------------------------------------------------------------- /hlivekit/pubsub.go: -------------------------------------------------------------------------------- 1 | package hlivekit 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "sync" 7 | 8 | "github.com/SamHennessy/hlive" 9 | "github.com/teris-io/shortid" 10 | ) 11 | 12 | type QueueMessage struct { 13 | Topic string 14 | Value any 15 | } 16 | 17 | type QueueSubscriber interface { 18 | GetID() string 19 | OnMessage(message QueueMessage) 20 | } 21 | 22 | type PubSubMounter interface { 23 | GetID() string 24 | PubSubMount(context.Context, *PubSub) 25 | } 26 | 27 | type PubSubAfterMounter interface { 28 | PubSubMounter 29 | AfterPubSubMount(context.Context, *PubSub) 30 | } 31 | 32 | type PubSubSSRMounter interface { 33 | GetID() string 34 | PubSubSSRMount(context.Context, *PubSub) 35 | } 36 | 37 | type PubSub struct { 38 | mu sync.RWMutex 39 | subscribers map[string][]QueueSubscriber 40 | } 41 | 42 | func NewPubSub() *PubSub { 43 | return &PubSub{ 44 | subscribers: map[string][]QueueSubscriber{}, 45 | } 46 | } 47 | 48 | func (ps *PubSub) Subscribe(sub QueueSubscriber, topics ...string) { 49 | if len(topics) == 0 { 50 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("no topics passed") 51 | 52 | return 53 | } 54 | 55 | if sub == nil { 56 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("sub nil") 57 | 58 | return 59 | } 60 | 61 | // A Publish can trigger a Subscribe. Subscribe will be added after the Publish 62 | go func() { 63 | ps.mu.Lock() 64 | defer ps.mu.Unlock() 65 | 66 | for i := 0; i < len(topics); i++ { 67 | ps.subscribers[topics[i]] = append(ps.subscribers[topics[i]], sub) 68 | } 69 | }() 70 | } 71 | 72 | func (ps *PubSub) SubscribeWait(sub QueueSubscriber, topics ...string) { 73 | if len(topics) == 0 { 74 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("no topics passed") 75 | 76 | return 77 | } 78 | 79 | if sub == nil { 80 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("sub nil") 81 | 82 | return 83 | } 84 | 85 | ps.mu.Lock() 86 | defer ps.mu.Unlock() 87 | 88 | for i := 0; i < len(topics); i++ { 89 | ps.subscribers[topics[i]] = append(ps.subscribers[topics[i]], sub) 90 | } 91 | } 92 | 93 | func (ps *PubSub) SubscribeWaitFunc(subFunc func(message QueueMessage), topics ...string) SubscribeFunc { 94 | sub := NewSub(subFunc) 95 | 96 | ps.SubscribeWait(sub, topics...) 97 | 98 | return sub 99 | } 100 | 101 | func (ps *PubSub) SubscribeFunc(subFunc func(message QueueMessage), topics ...string) SubscribeFunc { 102 | sub := NewSub(subFunc) 103 | 104 | ps.Subscribe(sub, topics...) 105 | 106 | return sub 107 | } 108 | 109 | func (ps *PubSub) UnsubscribeWait(sub QueueSubscriber, topics ...string) { 110 | if len(topics) == 0 { 111 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("no topics passed") 112 | } 113 | 114 | if sub == nil { 115 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("sub when nil") 116 | 117 | return 118 | } 119 | 120 | ps.mu.Lock() 121 | defer ps.mu.Unlock() 122 | 123 | for i := 0; i < len(topics); i++ { 124 | var newList []QueueSubscriber 125 | 126 | for j := 0; j < len(ps.subscribers[topics[i]]); j++ { 127 | if ps.subscribers[topics[i]][j].GetID() == sub.GetID() { 128 | continue 129 | } 130 | 131 | newList = append(newList, ps.subscribers[topics[i]][j]) 132 | } 133 | 134 | ps.subscribers[topics[i]] = newList 135 | } 136 | } 137 | 138 | func (ps *PubSub) Unsubscribe(sub QueueSubscriber, topics ...string) { 139 | if len(topics) == 0 { 140 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("no topics passed") 141 | } 142 | 143 | if sub == nil { 144 | hlive.LoggerDev.Warn().Str("callers", hlive.CallerStackStr()).Msg("sub when nil") 145 | 146 | return 147 | } 148 | 149 | go func() { 150 | ps.mu.Lock() 151 | defer ps.mu.Unlock() 152 | 153 | for i := 0; i < len(topics); i++ { 154 | var newList []QueueSubscriber 155 | 156 | for j := 0; j < len(ps.subscribers[topics[i]]); j++ { 157 | if ps.subscribers[topics[i]][j].GetID() == sub.GetID() { 158 | continue 159 | } 160 | 161 | newList = append(newList, ps.subscribers[topics[i]][j]) 162 | } 163 | 164 | ps.subscribers[topics[i]] = newList 165 | } 166 | }() 167 | } 168 | 169 | func (ps *PubSub) Publish(topic string, value any) { 170 | // Multiple Publish calls can run concurrently 171 | ps.mu.RLock() 172 | defer ps.mu.RUnlock() 173 | 174 | item := QueueMessage{topic, value} 175 | for i := 0; i < len(ps.subscribers[topic]); i++ { 176 | ps.subscribers[topic][i].OnMessage(item) 177 | } 178 | } 179 | 180 | type SubscribeFunc struct { 181 | fn func(message QueueMessage) 182 | id string 183 | } 184 | 185 | func (s SubscribeFunc) OnMessage(message QueueMessage) { 186 | s.fn(message) 187 | } 188 | 189 | func (s SubscribeFunc) GetID() string { 190 | return s.id 191 | } 192 | 193 | func NewSub(onMessageFn func(message QueueMessage)) SubscribeFunc { 194 | return SubscribeFunc{onMessageFn, shortid.MustGenerate()} 195 | } 196 | 197 | const PipelineProcessorKeyPubSubMount = "hlivekit_ps_mount" 198 | 199 | func (a *PubSubAttribute) PipelineProcessorPubSub() *hlive.PipelineProcessor { 200 | pp := hlive.NewPipelineProcessor(PipelineProcessorKeyPubSubMount) 201 | 202 | pp.BeforeTagger = func(ctx context.Context, w io.Writer, tag hlive.Tagger) (hlive.Tagger, error) { 203 | if comp, ok := tag.(PubSubMounter); ok { 204 | a.mu.Lock() 205 | defer a.mu.Unlock() 206 | 207 | if _, exists := a.mountedMap[comp.GetID()]; !exists { 208 | a.mountedMap[comp.GetID()] = struct{}{} 209 | comp.PubSubMount(ctx, a.pubSub) 210 | 211 | if afterComp, ok := comp.(PubSubAfterMounter); ok { 212 | a.afterMountQueue = append(a.afterMountQueue, afterComp) 213 | } 214 | } 215 | 216 | // A way to remove the key when you delete a Component 217 | if comp, ok := tag.(hlive.Teardowner); ok { 218 | comp.AddTeardown(func() { 219 | a.mu.Lock() 220 | delete(a.mountedMap, comp.GetID()) 221 | a.mu.Unlock() 222 | }) 223 | } 224 | } 225 | 226 | return tag, nil 227 | } 228 | 229 | pp.AfterWalk = func(ctx context.Context, _ io.Writer, node *hlive.NodeGroup) (*hlive.NodeGroup, error) { 230 | a.mu.Lock() 231 | defer a.mu.Unlock() 232 | 233 | queue := append([]PubSubAfterMounter{}, a.afterMountQueue...) 234 | a.afterMountQueue = nil 235 | 236 | // People will want to be able to publish and render 237 | go func() { 238 | for i := 0; i < len(queue); i++ { 239 | queue[i].AfterPubSubMount(ctx, a.pubSub) 240 | } 241 | }() 242 | 243 | return node, nil 244 | } 245 | 246 | return pp 247 | } 248 | 249 | const PubSubAttributeName = "data-hlive-pubsub" 250 | 251 | func InstallPubSub(pubSub *PubSub) hlive.Attributer { 252 | attr := &PubSubAttribute{ 253 | Attribute: hlive.NewAttribute(PubSubAttributeName, ""), 254 | pubSub: pubSub, 255 | mountedMap: map[string]struct{}{}, 256 | } 257 | 258 | return attr 259 | } 260 | 261 | type PubSubAttribute struct { 262 | *hlive.Attribute 263 | 264 | pubSub *PubSub 265 | // Will memory leak is you don't use a Teardowner when deleting Components 266 | mountedMap map[string]struct{} 267 | afterMountQueue []PubSubAfterMounter 268 | mu sync.Mutex 269 | rendered bool 270 | } 271 | 272 | func (a *PubSubAttribute) Initialize(page *hlive.Page) { 273 | if a.rendered { 274 | return 275 | } 276 | 277 | page.PipelineDiff().Add(a.PipelineProcessorPubSub()) 278 | } 279 | 280 | func (a *PubSubAttribute) InitializeSSR(page *hlive.Page) { 281 | a.rendered = true 282 | page.PipelineDiff().Add(a.PipelineProcessorPubSub()) 283 | } 284 | 285 | // ComponentPubSub add PubSub to ComponentMountable 286 | type ComponentPubSub struct { 287 | *hlive.ComponentMountable 288 | 289 | mountPubSubFunc *hlive.LockBox[func(ctx context.Context, pubSub *PubSub)] 290 | afterMountPubSubFunc *hlive.LockBox[func(ctx context.Context, pubSub *PubSub)] 291 | } 292 | 293 | // CPS is a shortcut for NewComponentPubSub 294 | func CPS(name string, elements ...any) *ComponentPubSub { 295 | return NewComponentPubSub(name, elements...) 296 | } 297 | 298 | func NewComponentPubSub(name string, elements ...any) *ComponentPubSub { 299 | return WrapComponentPubSub(hlive.NewComponentMountable(name, elements...)) 300 | } 301 | 302 | // WCPS is a shortcut for WrapComponentPubSub 303 | func WCPS(name string, elements ...any) *ComponentPubSub { 304 | return NewComponentPubSub(name, elements...) 305 | } 306 | 307 | func WrapComponentPubSub(c *hlive.ComponentMountable) *ComponentPubSub { 308 | return &ComponentPubSub{ 309 | ComponentMountable: c, 310 | mountPubSubFunc: hlive.NewLockBox[func(ctx context.Context, pubSub *PubSub)](nil), 311 | afterMountPubSubFunc: hlive.NewLockBox[func(ctx context.Context, pubSub *PubSub)](nil), 312 | } 313 | } 314 | 315 | func (c *ComponentPubSub) PubSubMount(ctx context.Context, pubSub *PubSub) { 316 | f := c.mountPubSubFunc.Get() 317 | if f == nil { 318 | return 319 | } 320 | 321 | f(ctx, pubSub) 322 | } 323 | 324 | func (c *ComponentPubSub) AfterPubSubMount(ctx context.Context, pubSub *PubSub) { 325 | f := c.afterMountPubSubFunc.Get() 326 | if f == nil { 327 | return 328 | } 329 | 330 | f(ctx, pubSub) 331 | } 332 | 333 | func (c *ComponentPubSub) SetMountPubSub(f func(ctx context.Context, pubSub *PubSub)) { 334 | c.mountPubSubFunc.Set(f) 335 | } 336 | 337 | func (c *ComponentPubSub) SetAfterMountPubSub(f func(ctx context.Context, pubSub *PubSub)) { 338 | c.afterMountPubSubFunc.Set(f) 339 | } 340 | -------------------------------------------------------------------------------- /_example/pubsub/pubsub.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "regexp" 8 | 9 | l "github.com/SamHennessy/hlive" 10 | "github.com/SamHennessy/hlive/hlivekit" 11 | ) 12 | 13 | func main() { 14 | http.Handle("/", l.NewPageServer(home)) 15 | 16 | log.Println("INFO: listing :3000") 17 | 18 | if err := http.ListenAndServe(":3000", nil); err != nil { 19 | log.Println("ERRO: http listen and serve: ", err) 20 | } 21 | } 22 | 23 | const ( 24 | pstInputInvalid = "input_invalid" 25 | pstInputValid = "input_valid" 26 | pstFormValidate = "form_validate" 27 | pstFormInvalid = "form_invalid" 28 | pstFormSubmit = "form_submit" 29 | pstFormSubmitted = "form_summited" 30 | ) 31 | 32 | // Source: https://golangcode.com/validate-an-email-address/ 33 | var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") 34 | 35 | type inputValue struct { 36 | name string 37 | value *l.NodeBox[string] 38 | error *l.NodeBox[string] 39 | } 40 | 41 | func newInputValue(name string) inputValue { 42 | return inputValue{ 43 | name: name, 44 | value: l.Box(""), 45 | error: l.Box(""), 46 | } 47 | } 48 | 49 | func home() *l.Page { 50 | pubSub := hlivekit.NewPubSub() 51 | 52 | page := l.NewPage() 53 | page.DOM().HTML().Add(hlivekit.InstallPubSub(pubSub)) 54 | page.DOM().Title().Add("PubSub Example") 55 | page.DOM().Head().Add(l.T("link", 56 | l.Attrs{"rel": "stylesheet", "href": "https://classless.de/classless.css"})) 57 | 58 | page.DOM().Body().Add( 59 | l.T("h1", "PubSub"), 60 | l.T("blockquote", "Use the PubSub system to allow for decoupled components."), 61 | l.T("hr"), 62 | newErrorMessages(), 63 | newUserForm( 64 | newInputName(), 65 | newInputEmail(), 66 | l.C("button", "Submit"), 67 | l.T("p", "*Required"), 68 | ), 69 | newFormOutput(), 70 | ) 71 | 72 | return page 73 | } 74 | 75 | // 76 | // Components 77 | // 78 | 79 | // Error messages 80 | 81 | func newErrorMessages() *errorMessages { 82 | c := &errorMessages{ 83 | Component: l.C("div"), 84 | inputMap: map[string]inputValue{}, 85 | errMessage: l.Box(""), 86 | } 87 | 88 | c.initDOM() 89 | 90 | return c 91 | } 92 | 93 | type errorMessages struct { 94 | *l.Component 95 | 96 | pubSub *hlivekit.PubSub 97 | errMessage *l.NodeBox[string] 98 | inputs []string 99 | inputMap map[string]inputValue 100 | } 101 | 102 | func (c *errorMessages) PubSubMount(_ context.Context, pubSub *hlivekit.PubSub) { 103 | c.pubSub = pubSub 104 | 105 | // Track input updates 106 | pubSub.Subscribe(hlivekit.NewSub(c.onInput), pstInputInvalid, pstInputValid) 107 | 108 | // Reset 109 | pubSub.Subscribe(hlivekit.NewSub(c.onFormValidate), pstFormValidate) 110 | } 111 | 112 | func (c *errorMessages) onFormValidate(_ hlivekit.QueueMessage) { 113 | c.inputs = nil 114 | c.inputMap = map[string]inputValue{} 115 | c.errMessage.Set("") 116 | c.Add(l.Attrs{"display": "none"}) 117 | } 118 | 119 | func (c *errorMessages) onInput(message hlivekit.QueueMessage) { 120 | input, ok := message.Value.(inputValue) 121 | if !ok { 122 | return 123 | } 124 | 125 | _, exists := c.inputMap[input.name] 126 | 127 | if !exists { 128 | c.inputs = append(c.inputs, input.name) 129 | } 130 | 131 | c.inputMap[input.name] = input 132 | 133 | c.formatErrMessage() 134 | } 135 | 136 | func (c *errorMessages) initDOM() { 137 | c.Add(l.ClassBool{"card": true}, l.Style{"display": "none"}, 138 | l.T("h4", "Errors"), 139 | l.T("hr"), 140 | l.T("p", c.errMessage), 141 | ) 142 | } 143 | 144 | func (c *errorMessages) formatErrMessage() { 145 | c.errMessage.Set("") 146 | for i := 0; i < len(c.inputs); i++ { 147 | if c.inputMap[c.inputs[i]].error.Get() != "" { 148 | c.errMessage.Lock(func(v string) string { 149 | return v + c.inputMap[c.inputs[i]].error.Get() + " " 150 | }) 151 | } 152 | } 153 | 154 | if c.errMessage.Get() == "" { 155 | c.Add(l.Style{"display": "none"}) 156 | } else { 157 | c.Add(l.StyleOff{"display"}) 158 | } 159 | } 160 | 161 | // User form 162 | 163 | func newUserForm(nodes ...any) *userForm { 164 | c := &userForm{ 165 | Component: l.C("form", nodes...), 166 | } 167 | 168 | c.initDOM() 169 | 170 | return c 171 | } 172 | 173 | type userForm struct { 174 | *l.Component 175 | 176 | isInvalid bool 177 | pubSub *hlivekit.PubSub 178 | } 179 | 180 | func (c *userForm) PubSubMount(_ context.Context, pubSub *hlivekit.PubSub) { 181 | c.pubSub = pubSub 182 | 183 | // If any errors, then we can't submit 184 | pubSub.Subscribe(hlivekit.NewSub(func(message hlivekit.QueueMessage) { 185 | c.isInvalid = true 186 | }), pstInputInvalid) 187 | } 188 | 189 | func (c *userForm) initDOM() { 190 | // Revalidate the form, if invalid then submit 191 | c.Add(l.On("submit", c.onSubmit)) 192 | } 193 | 194 | func (c *userForm) onSubmit(_ context.Context, _ l.Event) { 195 | c.isInvalid = false 196 | 197 | // Revalidate form 198 | c.pubSub.Publish(pstFormValidate, nil) 199 | 200 | if c.isInvalid { 201 | c.pubSub.Publish(pstFormInvalid, nil) 202 | 203 | return 204 | } 205 | 206 | c.pubSub.Publish(pstFormSubmit, nil) 207 | } 208 | 209 | // Input, name 210 | 211 | func newInputName() *inputName { 212 | c := &inputName{ 213 | Component: l.NewComponent("span"), 214 | input: newInputValue("name"), 215 | } 216 | 217 | c.initDOM() 218 | 219 | return c 220 | } 221 | 222 | type inputName struct { 223 | *l.Component 224 | 225 | pubSub *hlivekit.PubSub 226 | input inputValue 227 | firstChange bool 228 | } 229 | 230 | func (c *inputName) PubSubMount(_ context.Context, pubSub *hlivekit.PubSub) { 231 | c.pubSub = pubSub 232 | 233 | c.pubSub.Subscribe(hlivekit.NewSub(c.onFormValidate), pstFormValidate) 234 | } 235 | 236 | func (c *inputName) initDOM() { 237 | c.Add( 238 | l.T("label", "Name*"), 239 | 240 | l.C("input", 241 | l.Attrs{"name": "name", "placeholder": "Your name"}, 242 | l.AttrsLockBox{"value": c.input.value.LockBox}, 243 | l.On("input", c.onInput), 244 | l.On("change", c.onChange), 245 | ), 246 | 247 | l.T("p", l.Style{"color": "red"}, c.input.error), 248 | ) 249 | } 250 | 251 | func (c *inputName) onFormValidate(_ hlivekit.QueueMessage) { 252 | c.firstChange = true 253 | c.validate() 254 | } 255 | 256 | func (c *inputName) onChange(ctx context.Context, e l.Event) { 257 | c.firstChange = true 258 | c.onInput(ctx, e) 259 | } 260 | 261 | func (c *inputName) onInput(_ context.Context, e l.Event) { 262 | c.input.value.Set(e.Value) 263 | 264 | if c.firstChange { 265 | c.validate() 266 | } 267 | } 268 | 269 | func (c *inputName) validate() { 270 | c.input.error.Set("") 271 | 272 | if c.input.value.Get() == "" { 273 | c.input.error.Set("Name is required.") 274 | c.pubSub.Publish(pstInputInvalid, c.input) 275 | 276 | return 277 | } 278 | 279 | if len([]rune(c.input.value.Get())) < 2 { 280 | c.input.error.Set("Name is too short.") 281 | c.pubSub.Publish(pstInputInvalid, c.input) 282 | 283 | return 284 | } 285 | 286 | c.pubSub.Publish(pstInputValid, c.input) 287 | } 288 | 289 | // Input, email 290 | 291 | func newInputEmail() *inputEmail { 292 | c := &inputEmail{ 293 | Component: l.NewComponent("span"), 294 | input: newInputValue("email"), 295 | } 296 | 297 | c.initDOM() 298 | 299 | return c 300 | } 301 | 302 | type inputEmail struct { 303 | *l.Component 304 | 305 | pubSub *hlivekit.PubSub 306 | input inputValue 307 | firstChange bool 308 | } 309 | 310 | func (c *inputEmail) PubSubMount(_ context.Context, pubSub *hlivekit.PubSub) { 311 | c.pubSub = pubSub 312 | 313 | c.pubSub.Subscribe(hlivekit.NewSub(c.onFormValidate), pstFormValidate) 314 | } 315 | 316 | func (c *inputEmail) initDOM() { 317 | c.Add( 318 | l.T("label", "Email"), 319 | 320 | l.C("input", 321 | l.Attrs{"name": "email", "placeholder": "Your email address"}, 322 | l.AttrsLockBox{"value": c.input.value.LockBox}, 323 | l.On("input", c.onInput), 324 | l.On("change", c.onChange), 325 | ), 326 | 327 | l.T("p", l.Style{"color": "red"}, c.input.error), 328 | ) 329 | } 330 | 331 | func (c *inputEmail) onFormValidate(_ hlivekit.QueueMessage) { 332 | c.firstChange = true 333 | c.validate() 334 | } 335 | 336 | func (c *inputEmail) onChange(ctx context.Context, e l.Event) { 337 | c.firstChange = true 338 | c.onInput(ctx, e) 339 | } 340 | 341 | func (c *inputEmail) onInput(_ context.Context, e l.Event) { 342 | c.input.value.Set(e.Value) 343 | 344 | if c.firstChange { 345 | c.validate() 346 | } 347 | } 348 | 349 | func (c *inputEmail) validate() { 350 | c.input.error.Set("") 351 | 352 | if len(c.input.value.Get()) != 0 && !emailRegex.MatchString(c.input.value.Get()) { 353 | c.input.error.Set("Email address not valid.") 354 | c.pubSub.Publish(pstInputInvalid, c.input) 355 | 356 | return 357 | } 358 | 359 | c.pubSub.Publish(pstInputValid, c.input) 360 | } 361 | 362 | // Form output 363 | 364 | func newFormOutput() *formOutput { 365 | c := &formOutput{ 366 | Component: l.C("table"), 367 | inputs: map[string]inputValue{}, 368 | list: hlivekit.List("tbody"), 369 | } 370 | 371 | c.Add( 372 | l.Style{"display": "none"}, 373 | l.T("thead", 374 | l.T("tr", 375 | l.T("th", "Key"), 376 | l.T("th", "Value"), 377 | ), 378 | ), 379 | c.list, 380 | ) 381 | 382 | return c 383 | } 384 | 385 | type formOutput struct { 386 | *l.Component 387 | 388 | list *hlivekit.ComponentList 389 | pubSub *hlivekit.PubSub 390 | inputs map[string]inputValue 391 | } 392 | 393 | func (c *formOutput) PubSubMount(_ context.Context, pubSub *hlivekit.PubSub) { 394 | c.pubSub = pubSub 395 | 396 | c.pubSub.Subscribe(hlivekit.NewSub(c.onValidInput), pstInputValid) 397 | c.pubSub.Subscribe(hlivekit.NewSub(c.onSubmitForm), pstFormSubmit) 398 | } 399 | 400 | func (c *formOutput) onValidInput(item hlivekit.QueueMessage) { 401 | if input, ok := item.Value.(inputValue); ok { 402 | c.inputs[input.name] = input 403 | } 404 | } 405 | 406 | func (c *formOutput) onSubmitForm(_ hlivekit.QueueMessage) { 407 | c.Add(l.StyleOff{"display"}) 408 | c.list.RemoveAllItems() 409 | for key, input := range c.inputs { 410 | c.list.Add( 411 | l.CM("tr", 412 | l.T("td", key), 413 | l.T("td", input.value), 414 | ), 415 | ) 416 | } 417 | 418 | c.pubSub.Publish(pstFormSubmitted, nil) 419 | } 420 | -------------------------------------------------------------------------------- /pipeline.go: -------------------------------------------------------------------------------- 1 | package hlive 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | var ErrDOMInvalidated = errors.New("dom invalidated") 11 | 12 | type ( 13 | PipeNodeHandler func(ctx context.Context, w io.Writer, node any) (any, error) 14 | PipeNodegroupHandler func(ctx context.Context, w io.Writer, node *NodeGroup) (*NodeGroup, error) 15 | PipeTaggerHandler func(ctx context.Context, w io.Writer, tagger Tagger) (Tagger, error) 16 | PipeTagHandler func(ctx context.Context, w io.Writer, tag *Tag) (*Tag, error) 17 | PipeAttributerHandler func(ctx context.Context, w io.Writer, tag Attributer) (Attributer, error) 18 | ) 19 | 20 | type Pipeline struct { 21 | processors []*PipelineProcessor 22 | processorMap map[string]*PipelineProcessor 23 | 24 | onSimpleNodeCache []*PipelineProcessor 25 | beforeWalkCache []*PipelineProcessor 26 | afterWalkCache []*PipelineProcessor 27 | beforeTaggerCache []*PipelineProcessor 28 | afterTaggerCache []*PipelineProcessor 29 | beforeAttrCache []*PipelineProcessor 30 | afterAttrCache []*PipelineProcessor 31 | // Add new caches to RemoveAll 32 | } 33 | 34 | func NewPipeline(pps ...*PipelineProcessor) *Pipeline { 35 | p := &Pipeline{processorMap: map[string]*PipelineProcessor{}} 36 | p.Add(pps...) 37 | 38 | return p 39 | } 40 | 41 | func (p *Pipeline) Add(processors ...*PipelineProcessor) { 42 | p.processors = append(p.processors, processors...) 43 | 44 | for i := 0; i < len(processors); i++ { 45 | if processors[i].Key != "" { 46 | p.processorMap[processors[i].Key] = processors[i] 47 | } 48 | 49 | if processors[i].OnSimpleNode != nil { 50 | p.onSimpleNodeCache = append(p.onSimpleNodeCache, processors[i]) 51 | } 52 | 53 | if processors[i].BeforeWalk != nil { 54 | p.beforeWalkCache = append(p.beforeWalkCache, processors[i]) 55 | } 56 | 57 | if processors[i].AfterWalk != nil { 58 | p.afterWalkCache = append(p.afterWalkCache, processors[i]) 59 | } 60 | 61 | if processors[i].BeforeTagger != nil { 62 | p.beforeTaggerCache = append(p.beforeTaggerCache, processors[i]) 63 | } 64 | 65 | if processors[i].AfterTagger != nil { 66 | p.afterTaggerCache = append(p.afterTaggerCache, processors[i]) 67 | } 68 | 69 | if processors[i].BeforeAttribute != nil { 70 | p.beforeAttrCache = append(p.beforeAttrCache, processors[i]) 71 | } 72 | 73 | if processors[i].AfterAttribute != nil { 74 | p.afterAttrCache = append(p.afterAttrCache, processors[i]) 75 | } 76 | } 77 | } 78 | 79 | func (p *Pipeline) RemoveAll() { 80 | p.processors = nil 81 | 82 | p.processorMap = map[string]*PipelineProcessor{} 83 | 84 | p.onSimpleNodeCache = nil 85 | p.beforeWalkCache = nil 86 | p.afterWalkCache = nil 87 | p.beforeTaggerCache = nil 88 | p.afterTaggerCache = nil 89 | p.beforeAttrCache = nil 90 | p.afterAttrCache = nil 91 | } 92 | 93 | func (p *Pipeline) AddAfter(processorKey string, processors ...*PipelineProcessor) { 94 | var newProcessors []*PipelineProcessor 95 | 96 | var hit bool 97 | 98 | for i := 0; i < len(p.processors); i++ { 99 | newProcessors = append(newProcessors, p.processors[i]) 100 | 101 | if p.processors[i].Key == processorKey { 102 | hit = true 103 | 104 | newProcessors = append(newProcessors, processors...) 105 | } 106 | } 107 | 108 | if !hit { 109 | newProcessors = append(newProcessors, processors...) 110 | } 111 | 112 | p.RemoveAll() 113 | p.Add(newProcessors...) 114 | } 115 | 116 | func (p *Pipeline) AddBefore(processorKey string, processors ...*PipelineProcessor) { 117 | var newProcessors []*PipelineProcessor 118 | 119 | var hit bool 120 | 121 | for i := 0; i < len(p.processors); i++ { 122 | if p.processors[i].Key == processorKey { 123 | hit = true 124 | newProcessors = append(newProcessors, processors...) 125 | } 126 | 127 | newProcessors = append(newProcessors, p.processors[i]) 128 | } 129 | 130 | if !hit { 131 | newProcessors = append(newProcessors, processors...) 132 | } 133 | 134 | p.RemoveAll() 135 | p.Add(newProcessors...) 136 | } 137 | 138 | func (p *Pipeline) onSimpleNode(ctx context.Context, w io.Writer, node any) (any, error) { 139 | for _, processor := range p.onSimpleNodeCache { 140 | if processor.Disabled { 141 | continue 142 | } 143 | 144 | newNode, err := processor.OnSimpleNode(ctx, w, node) 145 | if err != nil { 146 | return node, fmt.Errorf("onSimpleNode: %w", err) 147 | } 148 | 149 | node = newNode 150 | } 151 | 152 | return node, nil 153 | } 154 | 155 | func (p *Pipeline) afterWalk(ctx context.Context, w io.Writer, node *NodeGroup) (*NodeGroup, error) { 156 | for _, processor := range p.afterWalkCache { 157 | if processor.Disabled { 158 | continue 159 | } 160 | 161 | newNode, err := processor.AfterWalk(ctx, w, node) 162 | if err != nil { 163 | return node, fmt.Errorf("afterWalk: %w", err) 164 | } 165 | 166 | node = newNode 167 | } 168 | 169 | return node, nil 170 | } 171 | 172 | func (p *Pipeline) beforeWalk(ctx context.Context, w io.Writer, node *NodeGroup) (*NodeGroup, error) { 173 | for _, processor := range p.beforeWalkCache { 174 | if processor.Disabled || processor.BeforeWalk == nil { 175 | continue 176 | } 177 | 178 | newNode, err := processor.BeforeWalk(ctx, w, node) 179 | if err != nil { 180 | return node, fmt.Errorf("before: %w", err) 181 | } 182 | 183 | node = newNode 184 | } 185 | 186 | return node, nil 187 | } 188 | 189 | func (p *Pipeline) afterTagger(ctx context.Context, w io.Writer, node *Tag) (*Tag, error) { 190 | for _, processor := range p.afterTaggerCache { 191 | if processor.Disabled { 192 | continue 193 | } 194 | 195 | newNode, err := processor.AfterTagger(ctx, w, node) 196 | if err != nil { 197 | return node, fmt.Errorf("afterTagger: %w", err) 198 | } 199 | 200 | node = newNode 201 | } 202 | 203 | return node, nil 204 | } 205 | 206 | func (p *Pipeline) beforeTagger(ctx context.Context, w io.Writer, node Tagger) (Tagger, error) { 207 | for _, processor := range p.beforeTaggerCache { 208 | if processor.Disabled { 209 | continue 210 | } 211 | 212 | newNode, err := processor.BeforeTagger(ctx, w, node) 213 | if err != nil { 214 | return node, fmt.Errorf("beforeTagger: %w", err) 215 | } 216 | 217 | node = newNode 218 | } 219 | 220 | return node, nil 221 | } 222 | 223 | func (p *Pipeline) beforeAttr(ctx context.Context, w io.Writer, attr Attributer) (Attributer, error) { 224 | var err error 225 | for _, processor := range p.beforeAttrCache { 226 | if processor.Disabled { 227 | continue 228 | } 229 | 230 | attr, err = processor.BeforeAttribute(ctx, w, attr) 231 | if err != nil { 232 | return nil, fmt.Errorf("before attribute: %w", err) 233 | } 234 | } 235 | 236 | return attr, nil 237 | } 238 | 239 | func (p *Pipeline) afterAttr(ctx context.Context, w io.Writer, attr Attributer) (Attributer, error) { 240 | var err error 241 | for _, processor := range p.afterAttrCache { 242 | if processor.Disabled { 243 | continue 244 | } 245 | 246 | attr, err = processor.AfterAttribute(ctx, w, attr) 247 | if err != nil { 248 | return nil, fmt.Errorf("after attribute: %w", err) 249 | } 250 | } 251 | 252 | return attr, nil 253 | } 254 | 255 | // Run all the steps 256 | func (p *Pipeline) run(ctx context.Context, w io.Writer, nodeGroup *NodeGroup) (*NodeGroup, error) { 257 | nodeGroup, err := p.beforeWalk(ctx, w, nodeGroup) 258 | if err != nil { 259 | return nil, fmt.Errorf("run: beforeWalk: %w", err) 260 | } 261 | 262 | newGroup := G() 263 | list := nodeGroup.Get() 264 | for i := 0; i < len(list); i++ { 265 | newNode, err := p.walk(ctx, w, list[i]) 266 | if err != nil { 267 | return nil, fmt.Errorf("run: walk: %w", err) 268 | } 269 | 270 | newGroup.Add(newNode) 271 | } 272 | 273 | newGroup, err = p.afterWalk(ctx, w, newGroup) 274 | if err != nil { 275 | return nil, fmt.Errorf("run full tree: %w", err) 276 | } 277 | 278 | return newGroup, nil 279 | } 280 | 281 | // Skips some steps 282 | func (p *Pipeline) runNode(ctx context.Context, w io.Writer, node any) (any, error) { 283 | return p.walk(ctx, w, node) 284 | } 285 | 286 | func (p *Pipeline) walk(ctx context.Context, w io.Writer, node any) (any, error) { 287 | switch v := node.(type) { 288 | case nil: 289 | return nil, nil 290 | // Single Node, 291 | // Not a Tagger 292 | case string, HTML, 293 | int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: 294 | 295 | return p.onSimpleNode(ctx, w, node) 296 | // All Taggers wil be converted to a Tag 297 | case Tagger: 298 | if v.IsNil() { 299 | return nil, nil 300 | } 301 | 302 | v, err := p.beforeTagger(ctx, w, v) 303 | if err != nil { 304 | return nil, err 305 | } 306 | 307 | kids, err := p.walk(ctx, w, v.GetNodes()) 308 | if err != nil { 309 | return nil, err 310 | } 311 | 312 | oldAttrs := v.GetAttributes() 313 | var attrs []Attributer 314 | 315 | for i := 0; i < len(oldAttrs); i++ { 316 | attr := oldAttrs[i] 317 | 318 | attr, err = p.beforeAttr(ctx, w, attr) 319 | if err != nil { 320 | return nil, err 321 | } 322 | 323 | attr = oldAttrs[i].Clone() 324 | 325 | attr, err = p.afterAttr(ctx, w, attr) 326 | if err != nil { 327 | return nil, err 328 | } 329 | 330 | attrs = append(attrs, attr) 331 | } 332 | 333 | tag := T(v.GetName(), attrs, kids) 334 | tag.SetVoid(tag.IsVoid()) 335 | 336 | tag, err = p.afterTagger(ctx, w, tag) 337 | if err != nil { 338 | return tag, err 339 | } 340 | 341 | return tag, nil 342 | // 343 | // Lists, the following will all eventually be sent to the above simple node or Tagger cases 344 | // 345 | case *NodeGroup: 346 | if v == nil || len(v.Get()) == 0 { 347 | return nil, nil 348 | } 349 | 350 | var ( 351 | list = v.Get() 352 | newGroup []any 353 | 354 | thisNodeStr string 355 | thisNodeIsStr bool 356 | lastNodeStr string 357 | lastNodeIsStr bool 358 | ) 359 | 360 | for i := 0; i < len(list); i++ { 361 | if list[i] == nil { 362 | continue 363 | } 364 | 365 | node, err := p.walk(ctx, w, list[i]) 366 | if err != nil { 367 | return nil, err 368 | } 369 | 370 | if node == nil { 371 | continue 372 | } 373 | 374 | // Combine consecutive strings 375 | thisNodeStr, thisNodeIsStr = node.(string) 376 | 377 | // Combine strings like a browser would 378 | if lastNodeIsStr && thisNodeIsStr && len(newGroup) > 0 { 379 | // update this in case we have another string 380 | thisNodeStr = lastNodeStr + thisNodeStr 381 | // replace last node 382 | newGroup[len(newGroup)-1] = thisNodeStr 383 | } else { 384 | newGroup = append(newGroup, node) 385 | } 386 | // Update state for the next loop 387 | lastNodeStr, lastNodeIsStr = thisNodeStr, thisNodeIsStr 388 | } 389 | 390 | return newGroup, nil 391 | case []Componenter: 392 | g := G() 393 | 394 | for i := 0; i < len(v); i++ { 395 | g.Add(v[i]) 396 | } 397 | 398 | return p.walk(ctx, w, g) 399 | case []Tagger: 400 | g := G() 401 | 402 | for i := 0; i < len(v); i++ { 403 | g.Add(v[i]) 404 | } 405 | 406 | return p.walk(ctx, w, g) 407 | case []UniqueTagger: 408 | g := G() 409 | 410 | for i := 0; i < len(v); i++ { 411 | g.Add(v[i]) 412 | } 413 | 414 | return p.walk(ctx, w, g) 415 | case []*Component: 416 | g := G() 417 | 418 | for i := 0; i < len(v); i++ { 419 | g.Add(v[i]) 420 | } 421 | 422 | return p.walk(ctx, w, g) 423 | case []*Tag: 424 | g := G() 425 | 426 | for i := 0; i < len(v); i++ { 427 | g.Add(v[i]) 428 | } 429 | 430 | return p.walk(ctx, w, g) 431 | default: 432 | return nil, fmt.Errorf("pileline.walk: node: %#v: %w", v, ErrRenderElement) 433 | } 434 | } 435 | --------------------------------------------------------------------------------