diff --git a/html/element.go b/html/element.go
new file mode 100644
index 0000000..c4037b1
--- /dev/null
+++ b/html/element.go
@@ -0,0 +1,120 @@
+package html
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+)
+
+var _ HTMLer = new(Element)
+
+type Element struct {
+ tag string
+ attrs map[string]string
+ content HTML
+}
+
+func (e *Element) Attribute(name, value string) *Element {
+ e.attrs[name] = value
+ return e
+}
+
+func (e *Element) Class(class ...string) *Element {
+ return e.Attribute("class", strings.Join(class, " "))
+}
+
+func (e *Element) Href(href string) *Element {
+ return e.Attribute("href", href)
+}
+
+func (e *Element) Src(src string) *Element {
+ return e.Attribute("src", src)
+}
+
+func (e *Element) Style(style string) *Element {
+ return e.Attribute("style", style)
+}
+
+func content(v any) HTML {
+ switch v := v.(type) {
+ case nil:
+ return ""
+ case HTML:
+ return v
+ case HTMLer:
+ return v.HTML()
+ case string:
+ return HTML(EscapeString(v))
+ default:
+ return HTML(EscapeString(fmt.Sprint(v)))
+ }
+}
+
+func (e *Element) Content(v any) *Element {
+ e.content = content(v)
+ return e
+}
+
+func (e *Element) AppendContent(v any) *Element {
+ e.content += content(v)
+ return e
+}
+
+func (e *Element) AppendChild(child *Element) *Element {
+ return e.AppendContent(child)
+}
+
+// https://developer.mozilla.org/en-US/docs/Glossary/Void_element
+func (e Element) isVoidElement() bool {
+ return slices.Contains([]string{
+ "area",
+ "base",
+ "br",
+ "col",
+ "embed",
+ "hr",
+ "img",
+ "input",
+ "link",
+ "meta",
+ "param",
+ "source",
+ "track",
+ "wbr",
+ }, strings.ToLower(e.tag))
+}
+
+// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes
+func (e Element) printAttrs() string {
+ var s []string
+ for k, v := range e.attrs {
+ if v == "" || v == "true" {
+ s = append(s, k)
+ } else if v == "false" {
+ continue
+ } else {
+ s = append(s, fmt.Sprintf("%s=%q", k, v))
+ }
+ }
+ slices.Sort(s)
+ return strings.Join(s, " ")
+}
+
+func (e *Element) HTML() HTML {
+ var b strings.Builder
+ fmt.Fprint(&b, "<", e.tag)
+ if attrs := e.printAttrs(); attrs != "" {
+ fmt.Fprint(&b, " ", attrs)
+ }
+ if e.isVoidElement() {
+ fmt.Fprint(&b, ">")
+ } else {
+ fmt.Fprint(&b, ">", e.content)
+ fmt.Fprintf(&b, "%s>", e.tag)
+ }
+ return HTML(b.String())
+}
+
+func NewElement(tag string) *Element {
+ return &Element{tag, make(map[string]string), ""}
+}
diff --git a/html/element_test.go b/html/element_test.go
new file mode 100644
index 0000000..459800a
--- /dev/null
+++ b/html/element_test.go
@@ -0,0 +1,44 @@
+package html
+
+import "testing"
+
+func TestElement(t *testing.T) {
+ for _, tc := range []struct {
+ tag string
+ attrs [][2]string
+ content string
+ html HTML
+ }{
+ {"a", [][2]string{{"href", "/"}, {"style", "display:none"}}, "test", `test`},
+ {"p", nil, "test", "
test
"},
+ {"p", [][2]string{{"hidden", "true"}}, "test", "test
"},
+ {"p", [][2]string{{"hidden", "false"}}, "test", "test
"},
+ {"div", nil, "", "<test>
"},
+ } {
+ e := NewElement(tc.tag).Content(tc.content)
+ for _, i := range tc.attrs {
+ e.Attribute(i[0], i[1])
+ }
+ if res := e.HTML(); tc.html != res {
+ t.Errorf("expected %q; got %q", tc.html, res)
+ }
+ }
+ if div, expect := Div().Content(Br()).HTML(), "
"; expect != string(div) {
+ t.Errorf("expected %q; got %q", expect, div)
+ }
+}
+
+func TestAppend(t *testing.T) {
+ e := Div()
+ if expect := ""; expect != string(e.HTML()) {
+ t.Errorf("expected %q; got %q", expect, e.HTML())
+ }
+ e.AppendContent("test")
+ if expect := "test
"; expect != string(e.HTML()) {
+ t.Errorf("expected %q; got %q", expect, e.HTML())
+ }
+ e.AppendChild(Img().Src("test"))
+ if expect := `test
`; expect != string(e.HTML()) {
+ t.Errorf("expected %q; got %q", expect, e.HTML())
+ }
+}
diff --git a/html/html.go b/html/html.go
index 98f33cd..b00869a 100644
--- a/html/html.go
+++ b/html/html.go
@@ -1,53 +1,36 @@
package html
-import (
- "fmt"
- "html"
- "strings"
-)
+import "html"
var (
EscapeString = html.EscapeString
UnescapeString = html.UnescapeString
)
-type Attribute struct {
- Name string
- Value string
-}
-
-func Attributes(pairs ...string) (attributes []Attribute) {
- if len(pairs)%2 != 0 {
- panic("pairs must have even number of elements")
- }
- for i := 0; i < len(pairs); i = i + 2 {
- attributes = append(attributes, Attribute{pairs[i], pairs[i+1]})
- }
- return
-}
-
-func (attr Attribute) String() string {
- if attr.Value == "" || attr.Value == "true" {
- return attr.Name
- }
- return fmt.Sprintf("%s=%q", attr.Name, attr.Value)
-}
-
type HTML string
-func Element[T HTML | string](tag string, attributes []Attribute, content T) HTML {
- var b strings.Builder
- fmt.Fprint(&b, "<", tag)
- for _, i := range attributes {
- fmt.Fprint(&b, " ", i)
- }
- fmt.Fprint(&b, ">")
- switch any(content).(type) {
- case HTML:
- fmt.Fprint(&b, content)
- default:
- fmt.Fprint(&b, EscapeString(string(content)))
- }
- fmt.Fprintf(&b, "%s>", tag)
- return HTML(b.String())
+type HTMLer interface {
+ HTML() HTML
}
+
+func A() *Element { return NewElement("a") }
+func B() *Element { return NewElement("b") }
+func Br() *Element { return NewElement("br") }
+func Div() *Element { return NewElement("div") }
+func Em() *Element { return NewElement("em") }
+func Form() *Element { return NewElement("form") }
+func H1() *Element { return NewElement("h1") }
+func H2() *Element { return NewElement("h2") }
+func I() *Element { return NewElement("i") }
+func Img() *Element { return NewElement("img") }
+func Input() *Element { return NewElement("input") }
+func Label() *Element { return NewElement("label") }
+func Li() *Element { return NewElement("li") }
+func P() *Element { return NewElement("p") }
+func Span() *Element { return NewElement("span") }
+func Svg() *Element { return NewElement("svg") }
+func Table() *Element { return NewElement("table") }
+func Tbody() *Element { return NewElement("tbody") }
+func Title() *Element { return NewElement("title") }
+func Thead() *Element { return NewElement("thead") }
+func Ul() *Element { return NewElement("ul") }
diff --git a/html/html_test.go b/html/html_test.go
deleted file mode 100644
index 837b01a..0000000
--- a/html/html_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package html
-
-import "testing"
-
-func TestAttributes(t *testing.T) {
- defer func() {
- if e := recover(); e == nil {
- t.Error("expected panic")
- }
- }()
- Attributes("test")
-}
-
-func TestElement(t *testing.T) {
- for _, tc := range []struct {
- tag string
- attrs []string
- content string
- html HTML
- }{
- {"a", []string{"href", "/", "style", "display:none"}, "test", `test`},
- {"p", nil, "test", "test
"},
- {"p", []string{"hidden", ""}, "test", "test
"},
- {"div", nil, "", "<test>
"},
- } {
- if res := Element(tc.tag, Attributes(tc.attrs...), tc.content); tc.html != res {
- t.Errorf("expected %q; got %q", tc.html, res)
- }
- }
- if div, expect := Element("div", nil, HTML("
")), "
"; expect != string(div) {
- t.Errorf("expected %q; got %q", expect, div)
- }
-}
diff --git a/html/table.go b/html/table.go
new file mode 100644
index 0000000..4e6c1af
--- /dev/null
+++ b/html/table.go
@@ -0,0 +1,69 @@
+package html
+
+import "strconv"
+
+func Tr[T *TableHeader | *TableData](element ...T) *Element {
+ tr := NewElement("tr")
+ for _, i := range element {
+ tr.AppendContent(i)
+ }
+ return tr
+}
+
+var (
+ _ HTMLer = new(TableHeader)
+ _ HTMLer = new(TableData)
+)
+
+type (
+ TableHeader struct{ *Element }
+ TableData struct{ *Element }
+)
+
+func Th(content any) *TableHeader {
+ return &TableHeader{NewElement("th").Content(content)}
+}
+
+func (th *TableHeader) Abbr(abbr string) *TableHeader {
+ th.Element.Attribute("abbr", abbr)
+ return th
+}
+
+func (th *TableHeader) Colspan(n uint) *TableHeader {
+ th.Element.Attribute("colspan", strconv.FormatUint(uint64(n), 10))
+ return th
+}
+
+func (th *TableHeader) Headers(headers string) *TableHeader {
+ th.Element.Attribute("headers", headers)
+ return th
+}
+
+func (th *TableHeader) Rowspan(n uint) *TableHeader {
+ th.Element.Attribute("rowspan", strconv.FormatUint(uint64(n), 10))
+ return th
+}
+
+func (th *TableHeader) Scope(scope string) *TableHeader {
+ th.Element.Attribute("scope", scope)
+ return th
+}
+
+func Td(content any) *TableData {
+ return &TableData{NewElement("td").Content(content)}
+}
+
+func (td *TableData) Colspan(n uint) *TableData {
+ td.Element.Attribute("colspan", strconv.FormatUint(uint64(n), 10))
+ return td
+}
+
+func (td *TableData) Headers(headers string) *TableData {
+ td.Element.Attribute("headers", headers)
+ return td
+}
+
+func (td *TableData) Rowspan(n uint) *TableData {
+ td.Element.Attribute("rowspan", strconv.FormatUint(uint64(n), 10))
+ return td
+}
diff --git a/html/table_test.go b/html/table_test.go
new file mode 100644
index 0000000..ccb1f2a
--- /dev/null
+++ b/html/table_test.go
@@ -0,0 +1,12 @@
+package html
+
+import "testing"
+
+func TestTable(t *testing.T) {
+ table := ``
+ if e := Table().
+ AppendChild(Thead().AppendChild(Tr(Th("H1").Colspan(2), Th("H2")))).
+ AppendChild(Tbody().AppendChild(Tr(Td("B1"), Td("B2")))); string(e.HTML()) != table {
+ t.Errorf("expected %q; got %q", table, e.HTML())
+ }
+}