all repos — gomponents @ aa31c287a17885091d2dd9111f3eb798ee2ad91a

HTML components in pure Go

Merge remote-tracking branch 'upstream/main'

Alan Pearce
commit

aa31c287a17885091d2dd9111f3eb798ee2ad91a

parent

16f98a2bdbcfa112fb73fe318e88d497318ff8b3

M .github/workflows/ci.yml.github/workflows/ci.yml
@@ -22,13 +22,14 @@ matrix:
go: - "1.23" - "1.24" + - "1.25" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} check-latest: true
@@ -48,21 +49,33 @@ benchmark:
name: Benchmark runs-on: ubuntu-latest + strategy: + matrix: + go: + - "1.18" + - "1.19" + - "1.20" + - "1.21" + - "1.22" + - "1.23" + - "1.24" + - "1.25" + steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version-file: go.mod + go-version: ${{ matrix.go }} check-latest: true - name: Run Benchmarks run: | - echo "# Benchmark Results" >> $GITHUB_STEP_SUMMARY + echo "# Benchmark Results for Go ${{ matrix.go }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - go test -bench=. ./... 2>&1 | tee -a $GITHUB_STEP_SUMMARY + go test -bench . -benchmem ./... 2>&1 | tee -a $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY lint:
@@ -70,10 +83,10 @@ name: Lint
runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod check-latest: true
A AGENTS.md
@@ -0,0 +1,123 @@
+# gomponents Development Guide + +This is gomponents, an HTML component library written in pure Go that renders to HTML5. This guide provides instructions for AI assistants working on this codebase. + +## About gomponents + +gomponents enables building HTML components using pure Go functions instead of template languages. Key features: +- Type-safe HTML generation with compile-time guarantees +- No external dependencies in the core library +- Direct rendering to `io.Writer` for efficiency +- Support for all HTML5 elements and attributes +- Conditional rendering and data mapping helpers + +## Project Structure + +The project is organized into focused packages: + +- **Core (`gomponents.go`)**: Main interfaces (`Node`), element/attribute creators (`El`, `Attr`), text rendering (`Text`, `Raw`), and helpers (`Map`, `Group`, `If`, `Iff`) +- **html/**: All HTML5 elements and attributes as Go functions +- **components/**: Higher-level components like `HTML5` document structure and `Classes` helper +- **http/**: HTTP handler utilities for web servers +- **internal/examples/app/**: Example application showing usage patterns + +## Development Standards + +### Code Style +- Follow standard Go conventions +- Use clear, descriptive function names +- No external dependencies in core library +- Maintain backwards compatibility (library is stable/mature) +- HTML element/attribute names match their HTML equivalents exactly + +### Testing +- Run tests: `make test` or `go test -shuffle on ./...` +- Run linting: `make lint` or `golangci-lint run` +- Maintain 100% test coverage +- Use table-driven tests where appropriate +- Test both successful rendering and error cases + +### Performance Considerations +- Render directly to `io.Writer` without intermediate allocations +- Use `io.StringWriter` optimization when available +- Avoid reflection in hot paths +- Keep void element checks efficient + +## Key Concepts + +### Node Interface +Everything implements the core `Node` interface: +```go +type Node interface { + Render(w io.Writer) error +} +``` + +### Node Types +- `ElementType`: HTML elements and text content +- `AttributeType`: HTML attributes (render in different phase) + +### Void Elements +Self-closing HTML elements (br, img, input, etc.) are handled specially - non-attribute children are ignored to ensure valid HTML. + +### Attribute vs Element Disambiguation +Some HTML names conflict (e.g., `style`). Convention: +- Most common usage gets the simple name (`Style` for attribute) +- Alternative gets suffix (`StyleEl` for element) +- Both variants always exist + +## Common Patterns + +### Creating Elements +```go +// Basic element +Div(Class("container"), Text("Hello")) + +// Custom element +El("custom-element", Attr("data-value", "123")) +``` + +### Conditional Rendering +```go +If(condition, someNode) // Eager evaluation +Iff(condition, func() Node { // Lazy evaluation + return expensiveNode() +}) +``` + +### Data Mapping +```go +Map(items, func(item Item) Node { + return Li(Text(item.Name)) +}) +``` + +## Testing Guidelines + +Test both the structure and actual HTML output: +```go +func TestComponent(t *testing.T) { + node := MyComponent("test") + + var buf bytes.Buffer + err := node.Render(&buf) + // Check err and buf.String() +} +``` + +## HTML Generation Best Practices + +1. **Always escape user content**: Use `Text()` for user data, `Raw()` only for trusted HTML +2. **Leverage type safety**: Create typed component functions rather than generic ones +3. **Use Groups for multiple nodes**: Return `Group{node1, node2}` when multiple nodes needed +4. **Handle nil nodes gracefully**: The library safely ignores nil nodes during rendering + +## Contributing Guidelines + +- New HTML elements/attributes should follow HTML5 spec exactly +- Core library changes require careful consideration of backwards compatibility +- Performance optimizations welcome, but measure first +- Documentation should be clear and include examples +- All changes must include comprehensive tests + +This is a mature, stable library focused on simplicity and performance. Prefer clear, straightforward implementations over complex features.
A CLAUDE.md
@@ -0,0 +1,1 @@
+AGENTS.md
M MakefileMakefile
@@ -1,6 +1,6 @@
.PHONY: benchmark benchmark: - go test -bench=. ./... + go test -bench . -benchmem ./... .PHONY: cover cover:
M README.mdREADME.md
@@ -124,6 +124,27 @@ I accept code contributions, especially with new HTML elements and attributes.
I always welcome issues discussing interesting aspects of gomponents, and obviously bug reports and the like. But otherwise, I consider gomponents pretty much feature complete. +New features to the core library are unlikely to be merged, since I like keeping it simple and the API small. +In particular, new functions around collections (similar to `Map`) or flow control (`IfElse`/`Else`) will not be added. +`Map` was introduced before generics were a thing, and I think it's better to start using generic functions +from the stdlib or other libraries, instead of adding gomponents-specific variations of them to this library. + +If there's something missing that you need, I would recommend to keep small helper functions around in your own projects. +And if all else fails, you can always use an [IIFE](https://developer.mozilla.org/en-US/docs/Glossary/IIFE): + +```go +func list(ordered bool) Node { + return func() Node { + // Do whatever you need to do, imperatively + if ordered { + return Ol() + } else { + return Ul() + } + }() +} +``` + ### What's up with the specially named elements and attributes? Unfortunately, there are some name clashes in HTML elements and attributes, so they need an `El` or `Attr` suffix,
M components/components.gocomponents/components.go
@@ -64,3 +64,82 @@ var b strings.Builder
_ = c.Render(&b) return b.String() } + +// JoinAttrs with the given name only on the first level of the given nodes. +// This means that attributes on non-direct descendants are ignored. +// Attribute values are joined by spaces. +// Note that this renders all first-level attributes to check whether they should be processed. +func JoinAttrs(name string, children ...g.Node) g.Node { + var attrValues []string + var result []g.Node + firstAttrIndex := -1 + + // Process all children + for _, child := range children { + // Handle groups explicitly because they may contain attributes + if group, ok := child.(g.Group); ok { + for _, groupChild := range group { + isGivenAttr, attrValue := extractAttrValue(name, groupChild) + if !isGivenAttr || attrValue == "" { + result = append(result, groupChild) + continue + } + + attrValues = append(attrValues, attrValue) + if firstAttrIndex == -1 { + firstAttrIndex = len(result) + result = append(result, nil) + } + } + + continue + } + + // Handle non-group nodes essentially the same way + isGivenAttr, attrValue := extractAttrValue(name, child) + if !isGivenAttr || attrValue == "" { + result = append(result, child) + continue + } + + attrValues = append(attrValues, attrValue) + if firstAttrIndex == -1 { + firstAttrIndex = len(result) + result = append(result, nil) + } + } + + // If no attributes were found, just return the result now + if firstAttrIndex == -1 { + return g.Group(result) + } + + // Insert joined attribute at the position of the first attribute + result[firstAttrIndex] = g.Attr(name, strings.Join(attrValues, " ")) + return g.Group(result) +} + +type nodeTypeDescriber interface { + Type() g.NodeType +} + +func extractAttrValue(name string, n g.Node) (bool, string) { + // Ignore everything that is not an attribute + if n, ok := n.(nodeTypeDescriber); !ok || n.Type() == g.ElementType { + return false, "" + } + + var b strings.Builder + if err := n.Render(&b); err != nil { + return false, "" + } + + rendered := b.String() + if !strings.HasPrefix(rendered, " "+name+`="`) || !strings.HasSuffix(rendered, `"`) { + return false, "" + } + + v := strings.TrimPrefix(rendered, " "+name+`="`) + v = strings.TrimSuffix(v, `"`) + return true, v +}
M components/components_test.gocomponents/components_test.go
@@ -1,6 +1,8 @@
package components_test import ( + "errors" + "io" "os" "testing"
@@ -88,3 +90,63 @@ e := g.El("div", Classes{"party-hat": true, "boring-hat": false})
_ = e.Render(os.Stdout) // Output: <div class="party-hat"></div> } + +func hat(children ...g.Node) g.Node { + return Div(JoinAttrs("class", g.Group(children), Class("hat"))) +} + +func partyHat(children ...g.Node) g.Node { + return hat(ID("party-hat"), Class("party"), g.Group(children)) +} + +type brokenNode struct { + first bool +} + +func (b *brokenNode) Render(io.Writer) error { + if !b.first { + return nil + } + b.first = false + return errors.New("oh no") +} + +func (b *brokenNode) Type() g.NodeType { + return g.AttributeType +} + +func TestJoinAttrs(t *testing.T) { + t.Run("joins classes", func(t *testing.T) { + n := Div(JoinAttrs("class", Class("party"), ID("hey"), Class("hat"))) + assert.Equal(t, `<div class="party hat" id="hey"></div>`, n) + }) + + t.Run("joins classes in groups", func(t *testing.T) { + n := partyHat(Span(ID("party-hat-text"), Class("solid"), Class("gold"), g.Text("Yo."))) + assert.Equal(t, `<div id="party-hat" class="party hat"><span id="party-hat-text" class="solid" class="gold">Yo.</span></div>`, n) + }) + + t.Run("does nothing if attribute not found", func(t *testing.T) { + n := Div(JoinAttrs("style", Class("party"), ID("hey"), Class("hat"))) + assert.Equal(t, `<div class="party" id="hey" class="hat"></div>`, n) + }) + + t.Run("ignores nodes that can't render", func(t *testing.T) { + n := Div(JoinAttrs("class", Class("party"), ID("hey"), &brokenNode{first: true}, Class("hat"))) + assert.Equal(t, `<div class="party hat" id="hey"></div>`, n) + }) +} + +func myButton(children ...g.Node) g.Node { + return Div(JoinAttrs("class", g.Group(children), Class("button"))) +} + +func myPrimaryButton(text string) g.Node { + return myButton(Class("primary"), g.Text(text)) +} + +func ExampleJoinAttrs() { + danceButton := myPrimaryButton("Dance") + _ = danceButton.Render(os.Stdout) + // Output: <div class="primary button">Dance</div> +}
M gomponents.gogomponents.go
@@ -56,7 +56,7 @@ func (n NodeFunc) Render(w io.Writer) error {
return n(w) } -// Type satisfies nodeTypeDescriber. +// Type satisfies [nodeTypeDescriber]. func (n NodeFunc) Type() NodeType { return ElementType }
@@ -68,6 +68,12 @@ _ = n.Render(&b)
return b.String() } +var ( + lt = []byte("<") + gt = []byte(">") + ltSlash = []byte("</") +) + // El creates an element DOM [Node] with a name and child Nodes. // See https://dev.w3.org/html5/spec-LC/syntax.html#elements-0 for how elements are rendered. // No tags are ever omitted from normal tags, even though it's allowed for elements given at
@@ -76,76 +82,99 @@ // If an element is a void element, non-attribute children nodes are ignored.
// Use this if no convenience creator exists in the html package. func El(name string, children ...Node) Node { return NodeFunc(func(w io.Writer) error { - return render(w, &name, children...) - }) -} + var err error -func render(w2 io.Writer, name *string, children ...Node) error { - w := &statefulWriter{w: w2} + sw, ok := w.(io.StringWriter) - if name != nil { - w.Write([]byte("<" + *name)) + if _, err = w.Write(lt); err != nil { + return err + } + + if ok { + if _, err = sw.WriteString(name); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(name)); err != nil { + return err + } + } for _, c := range children { - renderChild(w, c, AttributeType) + if err = renderChild(w, c, AttributeType); err != nil { + return err + } + } + + if _, err = w.Write(gt); err != nil { + return err + } + + if isVoidElement(name) { + return nil } - w.Write([]byte(">")) + for _, c := range children { + if err = renderChild(w, c, ElementType); err != nil { + return err + } + } - if isVoidElement(*name) { - return w.err + if _, err = w.Write(ltSlash); err != nil { + return err } - } - for _, c := range children { - renderChild(w, c, ElementType) - } + if ok { + if _, err = sw.WriteString(name); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(name)); err != nil { + return err + } + } - if name != nil { - w.Write([]byte("</" + *name + ">")) - } + if _, err = w.Write(gt); err != nil { + return err + } - return w.err + return nil + }) } -// renderChild c to the given writer w if the node type is t. -func renderChild(w *statefulWriter, c Node, t NodeType) { - if w.err != nil || c == nil { - return +// renderChild c to the given writer w if the node type is desiredType. +func renderChild(w io.Writer, c Node, desiredType NodeType) error { + if c == nil { + return nil } // Rendering groups like this is still important even though a group can render itself, // since otherwise attributes will sometimes be ignored. if g, ok := c.(Group); ok { for _, groupC := range g { - renderChild(w, groupC, t) + if err := renderChild(w, groupC, desiredType); err != nil { + return err + } } - return + return nil } - switch t { + switch desiredType { case ElementType: - if p, ok := c.(nodeTypeDescriber); !ok || p.Type() == ElementType { - w.err = c.Render(w.w) + if p, ok := c.(nodeTypeDescriber); !ok || p.Type() == desiredType { + if err := c.Render(w); err != nil { + return err + } } case AttributeType: - if p, ok := c.(nodeTypeDescriber); ok && p.Type() == AttributeType { - w.err = c.Render(w.w) + if p, ok := c.(nodeTypeDescriber); ok && p.Type() == desiredType { + if err := c.Render(w); err != nil { + return err + } } } -} -// statefulWriter only writes if no errors have occurred earlier in its lifetime. -type statefulWriter struct { - w io.Writer - err error -} - -func (w *statefulWriter) Write(p []byte) { - if w.err != nil { - return - } - _, w.err = w.w.Write(p) + return nil } // voidElements don't have end tags and must be treated differently in the rendering.
@@ -174,44 +203,85 @@ _, ok := voidElements[name]
return ok } +var ( + space = []byte(" ") + equalQuote = []byte(`="`) + quote = []byte(`"`) +) + // Attr creates an attribute DOM [Node] with a name and optional value. // If only a name is passed, it's a name-only (boolean) attribute (like "required"). // If a name and value are passed, it's a name-value attribute (like `class="header"`). // More than one value make [Attr] panic. // Use this if no convenience creator exists in the html package. func Attr(name string, value ...string) Node { - switch len(value) { - case 0: - return &attr{name: name} - case 1: - return &attr{name: name, value: &value[0]} - default: + if len(value) > 1 { panic("attribute must be just name or name and value pair") } -} -type attr struct { - name string - value *string + return attrFunc(func(w io.Writer) error { + var err error + + sw, ok := w.(io.StringWriter) + + if _, err = w.Write(space); err != nil { + return err + } + + // Attribute name + if ok { + if _, err = sw.WriteString(name); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(name)); err != nil { + return err + } + } + + if len(value) == 0 { + return nil + } + + if _, err = w.Write(equalQuote); err != nil { + return err + } + + // Attribute value + if ok { + if _, err = sw.WriteString(template.HTMLEscapeString(value[0])); err != nil { + return err + } + } else { + if _, err = w.Write([]byte(template.HTMLEscapeString(value[0]))); err != nil { + return err + } + } + + if _, err = w.Write(quote); err != nil { + return err + } + + return nil + }) } + +// attrFunc is a render function that is also a [Node] of [AttributeType]. +// It's basically the same as [NodeFunc], but for attributes. +type attrFunc func(io.Writer) error // Render satisfies [Node]. -func (a *attr) Render(w io.Writer) error { - if a.value == nil { - _, err := w.Write([]byte(" " + a.name)) - return err - } - _, err := w.Write([]byte(" " + a.name + `="` + template.HTMLEscapeString(*a.value) + `"`)) - return err +func (a attrFunc) Render(w io.Writer) error { + return a(w) } // Type satisfies [nodeTypeDescriber]. -func (a *attr) Type() NodeType { +func (a attrFunc) Type() NodeType { return AttributeType } // String satisfies [fmt.Stringer]. -func (a *attr) String() string { +func (a attrFunc) String() string { var b strings.Builder _ = a.Render(&b) return b.String()
@@ -220,6 +290,10 @@
// Text creates a text DOM [Node] that Renders the escaped string t. func Text(t string) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(template.HTMLEscapeString(t)) + return err + } _, err := w.Write([]byte(template.HTMLEscapeString(t))) return err })
@@ -228,6 +302,10 @@
// Textf creates a text DOM [Node] that Renders the interpolated and escaped string format. func Textf(format string, a ...any) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(template.HTMLEscapeString(fmt.Sprintf(format, a...))) + return err + } _, err := w.Write([]byte(template.HTMLEscapeString(fmt.Sprintf(format, a...)))) return err })
@@ -236,6 +314,10 @@
// Raw creates a text DOM [Node] that just Renders the unescaped string t. func Raw(t string) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(t) + return err + } _, err := w.Write([]byte(t)) return err })
@@ -244,6 +326,10 @@
// Rawf creates a text DOM [Node] that just Renders the interpolated and unescaped string format. func Rawf(format string, a ...any) Node { return NodeFunc(func(w io.Writer) error { + if w, ok := w.(io.StringWriter); ok { + _, err := w.WriteString(fmt.Sprintf(format, a...)) + return err + } _, err := fmt.Fprintf(w, format, a...) return err })
@@ -332,7 +418,12 @@ }
// Render satisfies [Node]. func (g Group) Render(w io.Writer) error { - return render(w, nil, g...) + for _, c := range g { + if err := renderChild(w, c, ElementType); err != nil { + return err + } + } + return nil } // If condition is true, return the given [Node]. Otherwise, return nil.
M gomponents_test.gogomponents_test.go
@@ -62,16 +62,22 @@ }
func BenchmarkAttr(b *testing.B) { b.Run("boolean attributes", func(b *testing.B) { + var sb strings.Builder + for i := 0; i < b.N; i++ { a := g.Attr("hat") - _ = a.Render(&strings.Builder{}) + _ = a.Render(&sb) + sb.Reset() } }) b.Run("name-value attributes", func(b *testing.B) { + var sb strings.Builder + for i := 0; i < b.N; i++ { a := g.Attr("hat", "party") - _ = a.Render(&strings.Builder{}) + _ = a.Render(&sb) + sb.Reset() } }) }
@@ -148,17 +154,33 @@ assert.Equal(t, `<div><br><br></div>`, e)
}) t.Run("returns render error on cannot write", func(t *testing.T) { - e := g.El("div") - err := e.Render(&erroringWriter{}) - assert.Error(t, err) + // This weird little constructs makes sure we test error handling of all writes + for i := 0; i <= 33; i++ { + t.Run(fmt.Sprintf("failing write %v", i), func(t *testing.T) { + e := g.El("div", g.Attr("id", "hat"), g.Attr("required"), g.El("span"), + g.Group{g.El("span"), g.Attr("class", "party-hat")}, + g.Text("foo"), g.Textf("ba%v", "r"), g.Raw("baz"), g.Rawf("what comes after %v?", "baz")) + + w := &erroringWriter{failingWrite: i} + err := e.Render(w) + assert.Error(t, err) + + sw := &stringWriter{w: &erroringWriter{failingWrite: i}} + err = e.Render(sw) + assert.Error(t, err) + }) + } }) } func BenchmarkEl(b *testing.B) { b.Run("normal elements", func(b *testing.B) { + var sb strings.Builder + for i := 0; i < b.N; i++ { e := g.El("div") - _ = e.Render(&strings.Builder{}) + _ = e.Render(&sb) + sb.Reset() } }) }
@@ -169,10 +191,29 @@ _ = e.Render(os.Stdout)
// Output: <div><span></span></div> } -type erroringWriter struct{} +type erroringWriter struct { + failingWrite int + actualWrite int +} func (w *erroringWriter) Write(p []byte) (n int, err error) { - return 0, errors.New("no thanks") + if w.failingWrite == w.actualWrite { + return 0, errors.New("no thanks") + } + w.actualWrite++ + return 0, nil +} + +type stringWriter struct { + w io.Writer +} + +func (w *stringWriter) Write(p []byte) (n int, err error) { + return w.w.Write(p) +} + +func (w *stringWriter) WriteString(s string) (n int, err error) { + return w.w.Write([]byte(s)) } func TestText(t *testing.T) {
@@ -375,6 +416,12 @@ e := g.Group{g.El("div"), g.El("span")}
assert.Equal(t, "<div></div><span></span>", e) assert.Equal(t, "<div></div>", e[0]) assert.Equal(t, "<span></span>", e[1]) + }) + + t.Run("returns render error on cannot write", func(t *testing.T) { + e := g.Group{g.El("span")} + err := e.Render(&erroringWriter{}) + assert.Error(t, err) }) }
M html/attributes.gohtml/attributes.go
@@ -60,6 +60,10 @@ func Muted() g.Node {
return g.Attr("muted") } +func Open() g.Node { + return g.Attr("open") +} + func PlaysInline() g.Node { return g.Attr("playsinline") }
M html/attributes_test.gohtml/attributes_test.go
@@ -25,6 +25,7 @@ {Name: "formnovalidate", Func: FormNoValidate},
{Name: "loop", Func: Loop}, {Name: "multiple", Func: Multiple}, {Name: "muted", Func: Muted}, + {Name: "open", Func: Open}, {Name: "playsinline", Func: PlaysInline}, {Name: "readonly", Func: ReadOnly}, {Name: "required", Func: Required},
M html/elements_test.gohtml/elements_test.go
@@ -168,7 +168,7 @@ }
} func BenchmarkLargeHTMLDocument(b *testing.B) { - b.ReportAllocs() + var sb strings.Builder for i := 0; i < b.N; i++ { elements := make([]g.Node, 0, 10000)
@@ -181,7 +181,8 @@ )
} doc := Div(elements...) - var sb strings.Builder _ = doc.Render(&sb) + + sb.Reset() } }
M internal/assert/assert.gointernal/assert/assert.go
@@ -13,7 +13,10 @@ func Equal(t *testing.T, expected string, actual g.Node) {
t.Helper() var b strings.Builder - _ = actual.Render(&b) + err := actual.Render(&b) + if err != nil { + t.Fatal("error rendering actual:", err) + } if expected != b.String() { t.Fatalf(`expected "%v" but got "%v"`, expected, b.String()) }