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 = `
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 + } + } + } +}