├── _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("" + tag.GetName() + ">")); 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 |
--------------------------------------------------------------------------------