about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.editorconfig15
-rw-r--r--.github/workflows/go.yml42
-rw-r--r--LICENSE21
-rw-r--r--README.md8
-rw-r--r--go.mod3
-rw-r--r--gomponents.go100
-rw-r--r--gomponents_test.go75
7 files changed, 264 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1fd14ef
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,15 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 2
+indent_style = space
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[Makefile]
+indent_style = tab
+
+[*.go]
+indent_style = tab
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
new file mode 100644
index 0000000..e9ccec8
--- /dev/null
+++ b/.github/workflows/go.yml
@@ -0,0 +1,42 @@
+name: Go
+
+on:
+  push:
+    branches: [ master ]
+  pull_request:
+    branches: [ master ]
+
+jobs:
+  build:
+    name: Build
+    runs-on: ubuntu-latest
+    steps:
+    - name: Set up Go 1.x
+      uses: actions/setup-go@v2
+      with:
+        go-version: ^1.15
+      id: go
+
+    - name: Check out
+      uses: actions/checkout@v2
+
+    - name: Get dependencies
+      run: go get -v -t -d ./...
+
+    - name: Build
+      run: go build -v .
+
+    - name: Test
+      run: go test -v ./...
+
+  lint:
+    name: Lint
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out
+        uses: actions/checkout@v2
+
+      - name: Lint
+        uses: golangci/golangci-lint-action@v2
+        with:
+          version: v1.30
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5af54ef
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Maragu ApS
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1eaed1c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# gomponents
+
+gomponents are components of DOM nodes for Go, that can render to an HTML page.
+gomponents aims to make it easy to build HTML pages of reusable components,
+without the use of a template language. Think server-side-rendered React,
+but without the virtual DOM and diffing.
+
+The implementation is still incomplete, but usable. The API may change until version 1 is reached.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..5a1df18
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module "github.com/maragudk/gomponents"
+
+go 1.15
diff --git a/gomponents.go b/gomponents.go
new file mode 100644
index 0000000..bd7e09c
--- /dev/null
+++ b/gomponents.go
@@ -0,0 +1,100 @@
+package gomponents
+
+import (
+	"fmt"
+	"html/template"
+	"strings"
+)
+
+// Node is a DOM node that can Render itself to a string representation.
+type Node interface {
+	Render() string
+}
+
+// NodeFunc is render function that is also a Node.
+type NodeFunc func() string
+
+func (n NodeFunc) Render() string {
+	return n()
+}
+
+// El creates an element DOM Node with a name and child Nodes.
+// Use this if no convenience creator exists.
+func El(name string, children ...Node) NodeFunc {
+	return func() string {
+		var b, attrString, childrenString strings.Builder
+
+		b.WriteString("<")
+		b.WriteString(name)
+
+		if len(children) == 0 {
+			b.WriteString("/>")
+			return b.String()
+		}
+
+		for _, c := range children {
+			s := c.Render()
+			if _, ok := c.(attr); ok {
+				attrString.WriteString(s)
+				continue
+			}
+			childrenString.WriteString(c.Render())
+		}
+
+		b.WriteString(attrString.String())
+
+		if childrenString.Len() == 0 {
+			b.WriteString("/>")
+			return b.String()
+		}
+
+		b.WriteString(">")
+		b.WriteString(childrenString.String())
+		b.WriteString("</")
+		b.WriteString(name)
+		b.WriteString(">")
+		return b.String()
+	}
+}
+
+// Attr creates an attr DOM Node.
+// If one parameter is passed, it's a name-only attribute (like "required").
+// If two parameters are passed, it's a name-value attribute (like `class="header"`).
+// More parameter counts make Attr panic.
+// Use this if no convenience creator exists.
+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:
+		panic("attribute must be just name or name and value pair")
+	}
+}
+
+type attr struct {
+	name  string
+	value *string
+}
+
+func (a attr) Render() string {
+	if a.value == nil {
+		return fmt.Sprintf(" %v", a.name)
+	}
+	return fmt.Sprintf(` %v="%v"`, a.name, *a.value)
+}
+
+// Text creates a text DOM Node that Renders the escaped string t.
+func Text(t string) NodeFunc {
+	return func() string {
+		return template.HTMLEscaper(t)
+	}
+}
+
+// Raw creates a raw Node that just Renders the unescaped string t.
+func Raw(t string) NodeFunc {
+	return func() string {
+		return t
+	}
+}
diff --git a/gomponents_test.go b/gomponents_test.go
new file mode 100644
index 0000000..21ab30a
--- /dev/null
+++ b/gomponents_test.go
@@ -0,0 +1,75 @@
+package gomponents_test
+
+import (
+	"testing"
+
+	g "github.com/maragudk/gomponents"
+)
+
+func TestAttr(t *testing.T) {
+	t.Run("renders just the local name with one argument", func(t *testing.T) {
+		a := g.Attr("required")
+		equal(t, " required", a.Render())
+	})
+
+	t.Run("renders the name and value when given two arguments", func(t *testing.T) {
+		a := g.Attr("id", "hat")
+		equal(t, ` id="hat"`, a.Render())
+	})
+
+	t.Run("panics with more than two arguments", func(t *testing.T) {
+		called := false
+		defer func() {
+			if err := recover(); err != nil {
+				called = true
+			}
+		}()
+		g.Attr("name", "value", "what is this?")
+		if !called {
+			t.FailNow()
+		}
+	})
+}
+
+func TestEl(t *testing.T) {
+	t.Run("renders an empty element if no children given", func(t *testing.T) {
+		e := g.El("div")
+		equal(t, "<div/>", e.Render())
+	})
+
+	t.Run("renders an empty element if only attributes given as children", func(t *testing.T) {
+		e := g.El("div", g.Attr("class", "hat"))
+		equal(t, `<div class="hat"/>`, e.Render())
+	})
+
+	t.Run("renders an element, attributes, and element children", func(t *testing.T) {
+		e := g.El("div", g.Attr("class", "hat"), g.El("span"))
+		equal(t, `<div class="hat"><span/></div>`, e.Render())
+	})
+
+	t.Run("renders attributes at the correct place regardless of placement in parameter list", func(t *testing.T) {
+		e := g.El("div", g.El("span"), g.Attr("class", "hat"))
+		equal(t, `<div class="hat"><span/></div>`, e.Render())
+	})
+}
+
+func TestText(t *testing.T) {
+	t.Run("renders escaped text", func(t *testing.T) {
+		e := g.Text("<div/>")
+		equal(t, "&lt;div/&gt;", e.Render())
+	})
+}
+
+func TestRaw(t *testing.T) {
+	t.Run("renders raw text", func(t *testing.T) {
+		e := g.Raw("<div/>")
+		equal(t, "<div/>", e.Render())
+	})
+}
+
+func equal(t *testing.T, expected, actual string) {
+	if expected != actual {
+		t.Errorf("expected %v but got %v", expected, actual)
+		t.FailNow()
+	}
+}