From 7b83e2fde7a0d3338f1aff39a897e361b6aa510e Mon Sep 17 00:00:00 2001 From: maddalax Date: Mon, 30 Sep 2024 12:39:48 -0500 Subject: [PATCH 1/3] update attrs to use ordered map --- cli/htmgo/internal/dirutil/dir.go | 6 + cli/htmgo/runner.go | 5 - cli/htmgo/tasks/copyassets/bundle.go | 2 + framework/h/attribute.go | 83 ++++++++------ framework/h/command_test.go | 4 +- framework/h/lifecycle.go | 2 +- framework/h/render_test.go | 142 ++++++++++-------------- framework/h/renderables.go | 5 + framework/h/renderer.go | 74 ++++++++---- framework/h/tag.go | 75 ++++++------- framework/h/xhr.go | 22 ++-- framework/internal/datastructure/map.go | 84 ++++++++++++++ 12 files changed, 311 insertions(+), 193 deletions(-) create mode 100644 framework/internal/datastructure/map.go diff --git a/cli/htmgo/internal/dirutil/dir.go b/cli/htmgo/internal/dirutil/dir.go index 0fcc84c..2715277 100644 --- a/cli/htmgo/internal/dirutil/dir.go +++ b/cli/htmgo/internal/dirutil/dir.go @@ -17,6 +17,12 @@ func HasFileFromRoot(file string) bool { return err == nil } +func CreateHtmgoDir() { + if !HasFileFromRoot("__htmgo") { + CreateDirFromRoot("__htmgo") + } +} + func CreateDirFromRoot(dir string) error { cwd := process.GetWorkingDir() path := filepath.Join(cwd, dir) diff --git a/cli/htmgo/runner.go b/cli/htmgo/runner.go index 075a4d7..ab27c44 100644 --- a/cli/htmgo/runner.go +++ b/cli/htmgo/runner.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "github.com/maddalax/htmgo/cli/htmgo/internal" - "github.com/maddalax/htmgo/cli/htmgo/internal/dirutil" "github.com/maddalax/htmgo/cli/htmgo/tasks/astgen" "github.com/maddalax/htmgo/cli/htmgo/tasks/copyassets" "github.com/maddalax/htmgo/cli/htmgo/tasks/css" @@ -57,10 +56,6 @@ func main() { slog.Debug("Running task:", slog.String("task", taskName)) slog.Debug("working dir:", slog.String("dir", process.GetWorkingDir())) - if !dirutil.HasFileFromRoot("__htmgo") { - dirutil.CreateDirFromRoot("__htmgo") - } - if taskName == "watch" { fmt.Printf("Running in watch mode\n") os.Setenv("ENV", "development") diff --git a/cli/htmgo/tasks/copyassets/bundle.go b/cli/htmgo/tasks/copyassets/bundle.go index 75fed1c..a24da76 100644 --- a/cli/htmgo/tasks/copyassets/bundle.go +++ b/cli/htmgo/tasks/copyassets/bundle.go @@ -36,6 +36,8 @@ func getModuleVersion(modulePath string) (string, error) { } func CopyAssets() { + dirutil.CreateHtmgoDir() + moduleName := "github.com/maddalax/htmgo/framework" modulePath := module.GetDependencyPath(moduleName) diff --git a/framework/h/attribute.go b/framework/h/attribute.go index 92e3907..52cb922 100644 --- a/framework/h/attribute.go +++ b/framework/h/attribute.go @@ -3,27 +3,50 @@ package h import ( "fmt" "github.com/maddalax/htmgo/framework/hx" + "github.com/maddalax/htmgo/framework/internal/datastructure" "strings" ) -type AttributeMap map[string]any +type AttributeMap = map[string]any -func (m *AttributeMap) ToMap() map[string]string { - result := make(map[string]string) - for k, v := range *m { - switch v.(type) { - case AttributeMap: - m2 := v.(*AttributeMap).ToMap() - for _, a := range m2 { - result[k] = a - } - case string: - result[k] = v.(string) - default: - result[k] = fmt.Sprintf("%v", v) +type AttributeMapOrdered struct { + data *datastructure.OrderedMap[string, string] +} + +func (m *AttributeMapOrdered) Set(key string, value any) { + switch v := value.(type) { + case string: + m.data.Set(key, v) + case *AttributeMapOrdered: + v.Each(func(k string, v any) { + m.Set(k, v) + }) + case *AttributeR: + m.data.Set(v.Name, v.Value) + default: + m.data.Set(key, fmt.Sprintf("%v", value)) + } +} + +func (m *AttributeMapOrdered) Each(cb func(key string, value any)) { + m.data.Each(func(key string, value string) { + cb(key, value) + }) +} + +func (m *AttributeMapOrdered) Entries() []datastructure.MapEntry[string, string] { + return m.data.Entries() +} + +func NewAttributeMap(pairs ...string) *AttributeMapOrdered { + m := datastructure.NewOrderedMap[string, string]() + if len(pairs)%2 == 0 { + for i := 0; i < len(pairs); i++ { + m.Set(pairs[i], pairs[i+1]) + i++ } } - return result + return &AttributeMapOrdered{data: m} } func Attribute(key string, value string) *AttributeR { @@ -33,28 +56,24 @@ func Attribute(key string, value string) *AttributeR { } } -func AttributeList(children ...*AttributeR) *AttributeMap { - m := make(AttributeMap) - for _, child := range children { - m[child.Name] = child.Value +func AttributeList(children ...*AttributeR) *AttributeMapOrdered { + m := NewAttributeMap() + for _, c := range children { + m.Set(c.Name, c.Value) } - return &m + return m } -func Attributes(attrs *AttributeMap) *AttributeMap { - return attrs +func Attributes(attributes *AttributeMap) *AttributeMapOrdered { + m := NewAttributeMap() + for k, v := range *attributes { + m.Set(k, v) + } + return m } -func AttributePairs(pairs ...string) *AttributeMap { - if len(pairs)%2 != 0 { - return &AttributeMap{} - } - m := make(AttributeMap) - for i := 0; i < len(pairs); i++ { - m[pairs[i]] = pairs[i+1] - i++ - } - return &m +func AttributePairs(pairs ...string) *AttributeMapOrdered { + return NewAttributeMap(pairs...) } func Checked() Ren { diff --git a/framework/h/command_test.go b/framework/h/command_test.go index c184ceb..3743381 100644 --- a/framework/h/command_test.go +++ b/framework/h/command_test.go @@ -100,12 +100,12 @@ func TestIncrement(t *testing.T) { func TestSetInnerHtml(t *testing.T) { htmlContent := Div(Span(UnsafeRaw("inner content"))) - compareIgnoreSpaces(t, renderJs(t, SetInnerHtml(htmlContent)), "this.innerHTML = `
inner content
`;") + compareIgnoreSpaces(t, renderJs(t, SetInnerHtml(htmlContent)), "this.innerHTML = `
inner content
`;") } func TestSetOuterHtml(t *testing.T) { htmlContent := Div(Span(UnsafeRaw("outer content"))) - compareIgnoreSpaces(t, renderJs(t, SetOuterHtml(htmlContent)), "this.outerHTML = `
outer content
`;") + compareIgnoreSpaces(t, renderJs(t, SetOuterHtml(htmlContent)), "this.outerHTML = `
outer content
`;") } func TestAddAttribute(t *testing.T) { diff --git a/framework/h/lifecycle.go b/framework/h/lifecycle.go index 49ee7c0..01a3f82 100644 --- a/framework/h/lifecycle.go +++ b/framework/h/lifecycle.go @@ -24,7 +24,7 @@ func validateCommands(cmds []Command) { break case ComplexJsCommand: break - case *AttributeMap: + case *AttributeMapOrdered: break case *Element: panic(fmt.Sprintf("element is not allowed in lifecycle events. Got: %v", t)) diff --git a/framework/h/render_test.go b/framework/h/render_test.go index 3d5792f..738eecc 100644 --- a/framework/h/render_test.go +++ b/framework/h/render_test.go @@ -1,11 +1,8 @@ package h import ( - "bytes" "github.com/google/uuid" "github.com/stretchr/testify/assert" - "golang.org/x/net/html" - "sort" "strconv" "strings" "sync" @@ -13,43 +10,12 @@ import ( "time" ) -// Sort attributes of a node by attribute name -func sortAttributes(node *html.Node) { - if node.Type == html.ElementNode && len(node.Attr) > 1 { - sort.SliceStable(node.Attr, func(i, j int) bool { - return node.Attr[i].Key < node.Attr[j].Key - }) - } -} - -// Traverse and sort attributes in the entire HTML tree -func traverseAndSortAttributes(node *html.Node) { - sortAttributes(node) - for child := node.FirstChild; child != nil; child = child.NextSibling { - traverseAndSortAttributes(child) - } -} - -// Parse HTML, sort attributes, and render back to a string -func sortHtmlAttributes(input string) string { - // Parse the HTML string into a node tree - doc, err := html.Parse(strings.NewReader(input)) - if err != nil { - return "" - } - - // Traverse and sort attributes for each node - traverseAndSortAttributes(doc) - - // Use a buffer to capture the rendered HTML - var buf bytes.Buffer - err = html.Render(&buf, doc) - if err != nil { - return "" - } - - // Return the rendered HTML as a string - return buf.String() +func TestSimpleRender(t *testing.T) { + t.Parallel() + result := Render( + Div(Attribute("id", "my-div"), Attribute("class", "my-class")), + ) + assert.Equal(t, `
`, result) } func TestRender(t *testing.T) { @@ -57,10 +23,10 @@ func TestRender(t *testing.T) { div := Div( Id("my-div"), Attribute("data-attr-2", "value"), - Attributes(&AttributeMap{ - "data-attr-3": "value", - "data-attr-4": "value", - }), + AttributePairs( + "data-attr-3", "value", + "data-attr-4", "value", + ), HxBeforeRequest( SetText("before request"), ), @@ -73,17 +39,31 @@ func TestRender(t *testing.T) { Text("hello, child"), ) - div.attributes["data-attr-1"] = "value" + div.attributes.Set("data-attr-1", "value") - expectedRaw := `
hello, world
hello, child
` - expected := sortHtmlAttributes(expectedRaw) - result := sortHtmlAttributes(Render(div)) + expected := `
hello, world
hello, child
` + result := Render(div) assert.Equal(t, expected, result) } +func TestRenderAttributes_1(t *testing.T) { + t.Parallel() + div := Div( + AttributePairs("class", "bg-red-500"), + Attributes(&AttributeMap{ + "id": Id("my-div"), + }), + Attribute("disabled", "true"), + ) + assert.Equal(t, + `
`, + Render(div), + ) +} + func TestRawContent(t *testing.T) { t.Parallel() str := "
hello, world
" @@ -98,14 +78,14 @@ func TestConditional(t *testing.T) { Ternary(true, Text("true"), Text("false")), ), ) - assert.Equal(t, "
true
", result) + assert.Equal(t, "
true
", result) result = Render( Div( If(false, Text("true")), ), ) - assert.Equal(t, "
", result) + assert.Equal(t, "
", result) } func TestTagSelfClosing(t *testing.T) { @@ -121,7 +101,7 @@ func TestTagSelfClosing(t *testing.T) { assert.Equal(t, `
`, Render( Div(Id("test")), )) - assert.Equal(t, `
`, Render( + assert.Equal(t, `
`, Render( Div(Id("test"), Div()), )) } @@ -134,12 +114,12 @@ func TestCached(t *testing.T) { return ComplexPage() }) - firstRender := sortHtmlAttributes(Render(page())) - secondRender := sortHtmlAttributes(Render(page())) + firstRender := Render(page()) + secondRender := Render(page()) assert.Equal(t, firstRender, secondRender) assert.Equal(t, 1, count) - assert.Equal(t, firstRender, sortHtmlAttributes(Render(ComplexPage()))) + assert.Equal(t, firstRender, Render(ComplexPage())) } func TestCachedT(t *testing.T) { @@ -150,12 +130,12 @@ func TestCachedT(t *testing.T) { return ComplexPage() }) - firstRender := sortHtmlAttributes(Render(page("a"))) - secondRender := sortHtmlAttributes(Render(page("a"))) + firstRender := Render(page("a")) + secondRender := Render(page("a")) assert.Equal(t, firstRender, secondRender) assert.Equal(t, 1, count) - assert.Equal(t, firstRender, sortHtmlAttributes(Render(ComplexPage()))) + assert.Equal(t, firstRender, Render(ComplexPage())) } func TestCachedT2(t *testing.T) { @@ -166,12 +146,12 @@ func TestCachedT2(t *testing.T) { return ComplexPage() }) - firstRender := sortHtmlAttributes(Render(page("a", "b"))) - secondRender := sortHtmlAttributes(Render(page("a", "b"))) + firstRender := Render(page("a", "b")) + secondRender := Render(page("a", "b")) assert.Equal(t, firstRender, secondRender) assert.Equal(t, 1, count) - assert.Equal(t, firstRender, sortHtmlAttributes(Render(ComplexPage()))) + assert.Equal(t, firstRender, Render(ComplexPage())) } func TestCachedT3(t *testing.T) { @@ -182,12 +162,12 @@ func TestCachedT3(t *testing.T) { return ComplexPage() }) - firstRender := sortHtmlAttributes(Render(page("a", "b", "c"))) - secondRender := sortHtmlAttributes(Render(page("a", "b", "c"))) + firstRender := Render(page("a", "b", "c")) + secondRender := Render(page("a", "b", "c")) assert.Equal(t, firstRender, secondRender) assert.Equal(t, 1, count) - assert.Equal(t, firstRender, sortHtmlAttributes(Render(ComplexPage()))) + assert.Equal(t, firstRender, Render(ComplexPage())) } func TestCachedT4(t *testing.T) { @@ -198,12 +178,12 @@ func TestCachedT4(t *testing.T) { return ComplexPage() }) - firstRender := sortHtmlAttributes(Render(page("a", "b", "c", "d"))) - secondRender := sortHtmlAttributes(Render(page("a", "b", "c", "d"))) + firstRender := Render(page("a", "b", "c", "d")) + secondRender := Render(page("a", "b", "c", "d")) assert.Equal(t, firstRender, secondRender) assert.Equal(t, 1, count) - assert.Equal(t, firstRender, sortHtmlAttributes(Render(ComplexPage()))) + assert.Equal(t, firstRender, Render(ComplexPage())) } func TestCachedExpired(t *testing.T) { @@ -214,9 +194,9 @@ func TestCachedExpired(t *testing.T) { return ComplexPage() }) - firstRender := sortHtmlAttributes(Render(page())) + firstRender := Render(page()) time.Sleep(time.Millisecond * 5) - secondRender := sortHtmlAttributes(Render(page())) + secondRender := Render(page()) assert.Equal(t, firstRender, secondRender) assert.Equal(t, 2, count) @@ -409,9 +389,9 @@ func TestCacheByKeyT1_2(t *testing.T) { } }) - assert.Equal(t, "

one

", Render(cachedItem("one"))) - assert.Equal(t, "

two

", Render(cachedItem("two"))) - assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

one

", Render(cachedItem("one"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) assert.Equal(t, 2, renderCount) } @@ -425,10 +405,10 @@ func TestCacheByKeyT1Expired(t *testing.T) { } }) - assert.Equal(t, "

one

", Render(cachedItem("one"))) - assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

one

", Render(cachedItem("one"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) time.Sleep(time.Millisecond * 2) - assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) assert.Equal(t, 3, renderCount) } @@ -442,14 +422,14 @@ func TestCacheByKeyT1Expired_2(t *testing.T) { } }) - assert.Equal(t, "

one

", Render(cachedItem("one"))) + assert.Equal(t, "

one

", Render(cachedItem("one"))) time.Sleep(time.Millisecond * 3) - assert.Equal(t, "

two

", Render(cachedItem("two"))) - assert.Equal(t, "

two

", Render(cachedItem("two"))) - assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) time.Sleep(time.Millisecond * 3) - assert.Equal(t, "

one

", Render(cachedItem("one"))) - assert.Equal(t, "

two

", Render(cachedItem("two"))) + assert.Equal(t, "

one

", Render(cachedItem("one"))) + assert.Equal(t, "

two

", Render(cachedItem("two"))) assert.Equal(t, 3, renderCount) } @@ -529,7 +509,7 @@ func TestBackgroundCleaner(t *testing.T) { func TestEscapeHtml(t *testing.T) { t.Parallel() assert.Equal(t, "<script>alert(1)</script>", Render(Text(""))) - assert.Equal(t, "

<script>alert(1)</script>

", Render(Pf(""))) + assert.Equal(t, "

<script>alert(1)</script>

", Render(Pf(""))) } diff --git a/framework/h/renderables.go b/framework/h/renderables.go index b34ec91..510e954 100644 --- a/framework/h/renderables.go +++ b/framework/h/renderables.go @@ -5,6 +5,11 @@ type AttributeR struct { Value string } +type KeyValue[T any] struct { + Key string + Value T +} + type TextContent struct { Content string } diff --git a/framework/h/renderer.go b/framework/h/renderer.go index 2459a34..fbdb0df 100644 --- a/framework/h/renderer.go +++ b/framework/h/renderer.go @@ -38,6 +38,13 @@ var voidTags = map[string]bool{ type RenderContext struct { builder *strings.Builder scripts []string + next any + prev any +} + +func (ctx *RenderContext) PrevIsAttribute() bool { + _, ok := ctx.prev.(*AttributeR) + return ok } func (ctx *RenderContext) AddScript(funcName string, body string) { @@ -50,6 +57,29 @@ func (ctx *RenderContext) AddScript(funcName string, body string) { ctx.scripts = append(ctx.scripts, script) } +func each[T any](ctx *RenderContext, arr []T, cb func(T)) { + for i, r := range arr { + if i == len(arr)-1 { + ctx.next = nil + } else { + ctx.next = arr[i+1] + } + cb(r) + } +} + +func eachAttrMap(ctx *RenderContext, m *AttributeMapOrdered, cb func(string, string)) { + entries := m.Entries() + for i, entry := range entries { + if i == len(entries)-1 { + ctx.next = nil + } else { + ctx.next = entries[i+1] + } + cb(entry.Key, entry.Value) + } +} + func (node *Element) Render(context *RenderContext) { // some elements may not have a tag, such as a Fragment @@ -68,11 +98,9 @@ func (node *Element) Render(context *RenderContext) { if node.tag != "" { context.builder.WriteString("<") context.builder.WriteString(node.tag) - context.builder.WriteString(" ") - - for name, value := range node.attributes { - NewAttribute(name, value).Render(context) - } + eachAttrMap(context, node.attributes, func(key string, value string) { + NewAttribute(key, value).Render(context) + }) } totalChildren := 0 @@ -110,7 +138,7 @@ func (node *Element) Render(context *RenderContext) { // second pass, render any attributes within the tag for _, child := range node.children { switch child.(type) { - case *AttributeMap: + case *AttributeMapOrdered: child.Render(context) case *AttributeR: child.Render(context) @@ -132,7 +160,7 @@ func (node *Element) Render(context *RenderContext) { // render the children elements that are not attributes for _, child := range node.children { switch child.(type) { - case *AttributeMap: + case *AttributeMapOrdered: continue case *AttributeR: continue @@ -157,55 +185,59 @@ func (node *Element) Render(context *RenderContext) { func renderScripts(context *RenderContext) { for _, script := range context.scripts { context.builder.WriteString(script) + context.prev = script } context.scripts = []string{} } func (a *AttributeR) Render(context *RenderContext) { + context.builder.WriteString(" ") context.builder.WriteString(a.Name) if a.Value != "" { context.builder.WriteString(`=`) context.builder.WriteString(`"`) context.builder.WriteString(html.EscapeString(a.Value)) context.builder.WriteString(`"`) - } else { - context.builder.WriteString(" ") } + context.prev = a } func (t *TextContent) Render(context *RenderContext) { context.builder.WriteString(template.HTMLEscapeString(t.Content)) + context.prev = t } func (r *RawContent) Render(context *RenderContext) { context.builder.WriteString(r.Content) + context.prev = r } func (c *ChildList) Render(context *RenderContext) { for _, child := range c.Children { child.Render(context) + context.prev = child } } func (j SimpleJsCommand) Render(context *RenderContext) { context.builder.WriteString(j.Command) + context.prev = j } func (j ComplexJsCommand) Render(context *RenderContext) { context.builder.WriteString(j.Command) + context.prev = j } func (p *Partial) Render(context *RenderContext) { p.Root.Render(context) + context.prev = p } -func (m *AttributeMap) Render(context *RenderContext) { - m2 := m.ToMap() - - for k, v := range m2 { - context.builder.WriteString(" ") - NewAttribute(k, v).Render(context) - } +func (m *AttributeMapOrdered) Render(context *RenderContext) { + eachAttrMap(context, m, func(key string, value string) { + NewAttribute(key, value).Render(context) + }) } func (l *LifeCycle) fromAttributeMap(event string, key string, value string, context *RenderContext) { @@ -222,21 +254,21 @@ func (l *LifeCycle) Render(context *RenderContext) { for event, commands := range l.handlers { m[event] = "" - for _, command := range commands { + each(context, commands, func(command Command) { switch c := command.(type) { case SimpleJsCommand: m[event] += fmt.Sprintf("%s;", c.Command) case ComplexJsCommand: context.AddScript(c.TempFuncName, c.Command) m[event] += fmt.Sprintf("%s(this);", c.TempFuncName) - case *AttributeMap: - for k, v := range c.ToMap() { + case *AttributeMapOrdered: + eachAttrMap(context, c, func(k string, v string) { l.fromAttributeMap(event, k, v, context) - } + }) case *AttributeR: l.fromAttributeMap(event, c.Name, c.Value, context) } - } + }) } children := make([]Ren, 0) diff --git a/framework/h/tag.go b/framework/h/tag.go index 76e6e85..23fd3b4 100644 --- a/framework/h/tag.go +++ b/framework/h/tag.go @@ -11,7 +11,7 @@ type PartialFunc = func(ctx *RequestContext) *Partial type Element struct { tag string - attributes map[string]string + attributes *AttributeMapOrdered meta any children []Ren } @@ -50,7 +50,7 @@ func Tag(tag string, children ...Ren) *Element { return &Element{ tag: tag, children: children, - attributes: make(map[string]string), + attributes: NewAttributeMap(), } } @@ -69,57 +69,53 @@ func Body(children ...Ren) *Element { func Meta(name string, content string) *Element { return &Element{ tag: "meta", - attributes: map[string]string{ - "name": name, - "content": content, - }, + attributes: AttributePairs( + "name", name, + "content", content, + ), children: make([]Ren, 0), } } func LinkWithVersion(href string, rel string, version string) *Element { - attributeMap := AttributeMap{ - "href": href + "?v=" + version, - "rel": rel, - } return &Element{ - tag: "link", - attributes: attributeMap.ToMap(), - children: make([]Ren, 0), + tag: "link", + attributes: AttributePairs( + "href", href+"?v="+version, + "rel", rel, + ), + children: make([]Ren, 0), } } func Link(href string, rel string) *Element { - attributeMap := AttributeMap{ - "href": href, - "rel": rel, - } return &Element{ - tag: "link", - attributes: attributeMap.ToMap(), - children: make([]Ren, 0), + tag: "link", + attributes: AttributePairs( + "href", href, + "rel", rel, + ), + children: make([]Ren, 0), } } func ScriptWithVersion(url string, version string) *Element { - attributeMap := AttributeMap{ - "src": url + "?v=" + version, - } return &Element{ - tag: "script", - attributes: attributeMap.ToMap(), - children: make([]Ren, 0), + tag: "script", + attributes: AttributePairs( + "src", url+"?v="+version, + ), + children: make([]Ren, 0), } } func Script(url string) *Element { - attributeMap := AttributeMap{ - "src": url, - } return &Element{ - tag: "script", - attributes: attributeMap.ToMap(), - children: make([]Ren, 0), + tag: "script", + attributes: AttributePairs( + "src", url, + ), + children: make([]Ren, 0), } } @@ -177,13 +173,12 @@ func Value(value any) *AttributeR { } func Input(inputType string, children ...Ren) *Element { - attributeMap := AttributeMap{ - "type": inputType, - } return &Element{ - tag: "input", - attributes: attributeMap.ToMap(), - children: children, + tag: "input", + attributes: AttributePairs( + "type", inputType, + ), + children: children, } } @@ -257,7 +252,7 @@ func TagF(tag string, format string, args ...interface{}) *Element { An invocation can look like h.H3F("build simple and scalable systems with %s", "go + htmx", h.Class("-mt-4")), - where the args may be a mix of strings, *Element, *AttributeMap, *ChildList, *AttributeR + where the args may be a mix of strings, *Element, *AttributeMapOrdered, *ChildList, *AttributeR We need to separate the children from the format arguments */ children := make([]Ren, 0) @@ -266,7 +261,7 @@ func TagF(tag string, format string, args ...interface{}) *Element { switch d := arg.(type) { case *Element: children = append(children, d) - case *AttributeMap: + case *AttributeMapOrdered: children = append(children, d) case *ChildList: for _, child := range d.Children { diff --git a/framework/h/xhr.go b/framework/h/xhr.go index 0636f38..72f2ad0 100644 --- a/framework/h/xhr.go +++ b/framework/h/xhr.go @@ -2,46 +2,46 @@ package h import "github.com/maddalax/htmgo/framework/hx" -func Get(path string, trigger ...string) *AttributeMap { +func Get(path string, trigger ...string) *AttributeMapOrdered { return AttributeList(Attribute(hx.GetAttr, path), HxTriggerString(trigger...)) } -func GetPartial(partial PartialFunc, trigger ...string) *AttributeMap { +func GetPartial(partial PartialFunc, trigger ...string) *AttributeMapOrdered { return Get(GetPartialPath(partial), trigger...) } -func GetPartialWithQs(partial PartialFunc, qs *Qs, trigger string) *AttributeMap { +func GetPartialWithQs(partial PartialFunc, qs *Qs, trigger string) *AttributeMapOrdered { return Get(GetPartialPathWithQs(partial, qs), trigger) } -func GetWithQs(path string, qs *Qs, trigger string) *AttributeMap { +func GetWithQs(path string, qs *Qs, trigger string) *AttributeMapOrdered { return Get(SetQueryParams(path, qs), trigger) } -func PostPartial(partial PartialFunc, triggers ...string) *AttributeMap { +func PostPartial(partial PartialFunc, triggers ...string) *AttributeMapOrdered { return Post(GetPartialPath(partial), triggers...) } -func PostPartialWithQs(partial PartialFunc, qs *Qs, trigger ...string) *AttributeMap { +func PostPartialWithQs(partial PartialFunc, qs *Qs, trigger ...string) *AttributeMapOrdered { return Post(GetPartialPathWithQs(partial, qs), trigger...) } -func Post(url string, trigger ...string) *AttributeMap { +func Post(url string, trigger ...string) *AttributeMapOrdered { return AttributeList(Attribute(hx.PostAttr, url), HxTriggerString(trigger...)) } -func PostWithQs(url string, qs *Qs, trigger string) *AttributeMap { +func PostWithQs(url string, qs *Qs, trigger string) *AttributeMapOrdered { return Post(SetQueryParams(url, qs), trigger) } -func PostOnClick(url string) *AttributeMap { +func PostOnClick(url string) *AttributeMapOrdered { return Post(url, hx.ClickEvent) } -func PostPartialOnClick(partial PartialFunc) *AttributeMap { +func PostPartialOnClick(partial PartialFunc) *AttributeMapOrdered { return PostOnClick(GetPartialPath(partial)) } -func PostPartialOnClickQs(partial PartialFunc, qs *Qs) *AttributeMap { +func PostPartialOnClickQs(partial PartialFunc, qs *Qs) *AttributeMapOrdered { return PostOnClick(GetPartialPathWithQs(partial, qs)) } diff --git a/framework/internal/datastructure/map.go b/framework/internal/datastructure/map.go new file mode 100644 index 0000000..e4741d2 --- /dev/null +++ b/framework/internal/datastructure/map.go @@ -0,0 +1,84 @@ +package datastructure + +type MapEntry[K comparable, V any] struct { + Key K + Value V +} + +// OrderedMap is a generic data structure that maintains the order of keys. +type OrderedMap[K comparable, V any] struct { + keys []K + values map[K]V +} + +func (om *OrderedMap[K, V]) Each(cb func(key K, value V)) { + for _, key := range om.keys { + cb(key, om.values[key]) + } +} + +// Entries returns the key-value pairs in the order they were added. +func (om *OrderedMap[K, V]) Entries() []MapEntry[K, V] { + entries := make([]MapEntry[K, V], len(om.keys)) + for i, key := range om.keys { + entries[i] = MapEntry[K, V]{ + Key: key, + Value: om.values[key], + } + } + return entries +} + +// NewOrderedMap creates a new OrderedMap. +func NewOrderedMap[K comparable, V any]() *OrderedMap[K, V] { + return &OrderedMap[K, V]{ + keys: []K{}, + values: make(map[K]V), + } +} + +// Set adds or updates a key-value pair in the OrderedMap. +func (om *OrderedMap[K, V]) Set(key K, value V) { + // Check if the key already exists + if _, exists := om.values[key]; !exists { + om.keys = append(om.keys, key) // Append key to the keys slice if it's a new key + } + om.values[key] = value +} + +// Get retrieves a value by key. +func (om *OrderedMap[K, V]) Get(key K) (V, bool) { + value, exists := om.values[key] + return value, exists +} + +// Keys returns the keys in the order they were added. +func (om *OrderedMap[K, V]) Keys() []K { + return om.keys +} + +// Values returns the values in the order of their keys. +func (om *OrderedMap[K, V]) Values() []V { + values := make([]V, len(om.keys)) + for i, key := range om.keys { + values[i] = om.values[key] + } + + return values +} + +// Delete removes a key-value pair from the OrderedMap. +func (om *OrderedMap[K, V]) Delete(key K) { + if _, exists := om.values[key]; exists { + // Remove the key from the map + delete(om.values, key) + + // Remove the key from the keys slice + for i, k := range om.keys { + if k == key { + om.keys = append(om.keys[:i], om.keys[i+1:]...) + break + } + } + } +} From 3c4583c2b39c0979d0b1d4c4630c2e034799f57d Mon Sep 17 00:00:00 2001 From: maddalax Date: Mon, 30 Sep 2024 12:47:10 -0500 Subject: [PATCH 2/3] cleanup --- framework/h/attribute.go | 4 +-- framework/h/render_test.go | 8 +++--- framework/h/renderer.go | 52 ++++++-------------------------------- 3 files changed, 14 insertions(+), 50 deletions(-) diff --git a/framework/h/attribute.go b/framework/h/attribute.go index 52cb922..87f30c7 100644 --- a/framework/h/attribute.go +++ b/framework/h/attribute.go @@ -18,7 +18,7 @@ func (m *AttributeMapOrdered) Set(key string, value any) { case string: m.data.Set(key, v) case *AttributeMapOrdered: - v.Each(func(k string, v any) { + v.Each(func(k string, v string) { m.Set(k, v) }) case *AttributeR: @@ -28,7 +28,7 @@ func (m *AttributeMapOrdered) Set(key string, value any) { } } -func (m *AttributeMapOrdered) Each(cb func(key string, value any)) { +func (m *AttributeMapOrdered) Each(cb func(key string, value string)) { m.data.Each(func(key string, value string) { cb(key, value) }) diff --git a/framework/h/render_test.go b/framework/h/render_test.go index 738eecc..24c3e92 100644 --- a/framework/h/render_test.go +++ b/framework/h/render_test.go @@ -23,10 +23,10 @@ func TestRender(t *testing.T) { div := Div( Id("my-div"), Attribute("data-attr-2", "value"), - AttributePairs( - "data-attr-3", "value", - "data-attr-4", "value", - ), + Attributes(&AttributeMap{ + "data-attr-3": "value", + "data-attr-4": "value", + }), HxBeforeRequest( SetText("before request"), ), diff --git a/framework/h/renderer.go b/framework/h/renderer.go index fbdb0df..e31240f 100644 --- a/framework/h/renderer.go +++ b/framework/h/renderer.go @@ -38,13 +38,6 @@ var voidTags = map[string]bool{ type RenderContext struct { builder *strings.Builder scripts []string - next any - prev any -} - -func (ctx *RenderContext) PrevIsAttribute() bool { - _, ok := ctx.prev.(*AttributeR) - return ok } func (ctx *RenderContext) AddScript(funcName string, body string) { @@ -57,29 +50,6 @@ func (ctx *RenderContext) AddScript(funcName string, body string) { ctx.scripts = append(ctx.scripts, script) } -func each[T any](ctx *RenderContext, arr []T, cb func(T)) { - for i, r := range arr { - if i == len(arr)-1 { - ctx.next = nil - } else { - ctx.next = arr[i+1] - } - cb(r) - } -} - -func eachAttrMap(ctx *RenderContext, m *AttributeMapOrdered, cb func(string, string)) { - entries := m.Entries() - for i, entry := range entries { - if i == len(entries)-1 { - ctx.next = nil - } else { - ctx.next = entries[i+1] - } - cb(entry.Key, entry.Value) - } -} - func (node *Element) Render(context *RenderContext) { // some elements may not have a tag, such as a Fragment @@ -98,7 +68,7 @@ func (node *Element) Render(context *RenderContext) { if node.tag != "" { context.builder.WriteString("<") context.builder.WriteString(node.tag) - eachAttrMap(context, node.attributes, func(key string, value string) { + node.attributes.Each(func(key string, value string) { NewAttribute(key, value).Render(context) }) } @@ -185,7 +155,6 @@ func (node *Element) Render(context *RenderContext) { func renderScripts(context *RenderContext) { for _, script := range context.scripts { context.builder.WriteString(script) - context.prev = script } context.scripts = []string{} } @@ -199,43 +168,36 @@ func (a *AttributeR) Render(context *RenderContext) { context.builder.WriteString(html.EscapeString(a.Value)) context.builder.WriteString(`"`) } - context.prev = a } func (t *TextContent) Render(context *RenderContext) { context.builder.WriteString(template.HTMLEscapeString(t.Content)) - context.prev = t } func (r *RawContent) Render(context *RenderContext) { context.builder.WriteString(r.Content) - context.prev = r } func (c *ChildList) Render(context *RenderContext) { for _, child := range c.Children { child.Render(context) - context.prev = child } } func (j SimpleJsCommand) Render(context *RenderContext) { context.builder.WriteString(j.Command) - context.prev = j } func (j ComplexJsCommand) Render(context *RenderContext) { context.builder.WriteString(j.Command) - context.prev = j } func (p *Partial) Render(context *RenderContext) { p.Root.Render(context) - context.prev = p } func (m *AttributeMapOrdered) Render(context *RenderContext) { - eachAttrMap(context, m, func(key string, value string) { + m.Each(func(key string, value string) { NewAttribute(key, value).Render(context) }) } @@ -254,7 +216,8 @@ func (l *LifeCycle) Render(context *RenderContext) { for event, commands := range l.handlers { m[event] = "" - each(context, commands, func(command Command) { + + for _, command := range commands { switch c := command.(type) { case SimpleJsCommand: m[event] += fmt.Sprintf("%s;", c.Command) @@ -262,13 +225,14 @@ func (l *LifeCycle) Render(context *RenderContext) { context.AddScript(c.TempFuncName, c.Command) m[event] += fmt.Sprintf("%s(this);", c.TempFuncName) case *AttributeMapOrdered: - eachAttrMap(context, c, func(k string, v string) { - l.fromAttributeMap(event, k, v, context) + c.Each(func(key string, value string) { + l.fromAttributeMap(event, key, value, context) }) case *AttributeR: l.fromAttributeMap(event, c.Name, c.Value, context) } - }) + } + } children := make([]Ren, 0) From e5c501481275309fbd00898786365e92f1656ccb Mon Sep 17 00:00:00 2001 From: maddalax Date: Mon, 30 Sep 2024 13:01:51 -0500 Subject: [PATCH 3/3] more tests --- framework/h/attribute.go | 8 ++++++-- framework/h/render_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/framework/h/attribute.go b/framework/h/attribute.go index 87f30c7..9651607 100644 --- a/framework/h/attribute.go +++ b/framework/h/attribute.go @@ -17,9 +17,13 @@ func (m *AttributeMapOrdered) Set(key string, value any) { switch v := value.(type) { case string: m.data.Set(key, v) + case *AttributeMap: + for k, v2 := range *v { + m.Set(k, v2) + } case *AttributeMapOrdered: - v.Each(func(k string, v string) { - m.Set(k, v) + v.Each(func(k string, v2 string) { + m.Set(k, v2) }) case *AttributeR: m.data.Set(v.Name, v.Value) diff --git a/framework/h/render_test.go b/framework/h/render_test.go index 24c3e92..10a6662 100644 --- a/framework/h/render_test.go +++ b/framework/h/render_test.go @@ -64,6 +64,39 @@ func TestRenderAttributes_1(t *testing.T) { ) } +func TestRenderAttributes_2(t *testing.T) { + div := Div( + AttributePairs("class", "bg-red-500", "id", "my-div"), + Button( + AttributePairs("class", "bg-blue-500", "id", "my-button"), + Text("Click me"), + Attribute("disabled", "true"), + Attribute("data-attr", "value"), + ), + ) + + assert.Equal(t, + `
`, + Render(div)) +} + +func TestRenderEmptyDiv(t *testing.T) { + t.Parallel() + assert.Equal(t, + `
`, + Render(Div()), + ) +} + +func TestRenderVoidElement(t *testing.T) { + t.Parallel() + assert.Equal(t, + ``, + Render(Input("text")), + ) + assert.Equal(t, ``, Render(Tag("input"))) +} + func TestRawContent(t *testing.T) { t.Parallel() str := "
hello, world
"