diff --git a/atomx/atomx_test.go b/atomx/atomx_test.go
index 2e37c32..77309c9 100644
--- a/atomx/atomx_test.go
+++ b/atomx/atomx_test.go
@@ -2,6 +2,8 @@ package atomx
import (
"testing"
+
+ "github.com/stretchr/testify/require"
)
func Test_Atoms_String(t *testing.T) {
@@ -37,3 +39,67 @@ func Test_Atoms_Has(t *testing.T) {
t.Fatalf("expected %v, got %v", exp, act)
}
}
+
+func Test_Atom_String(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.Equal("div", Div.String())
+ r.Equal("a", A.String())
+}
+
+func Test_Atom_Atom(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.Equal(Div, Div.Atom())
+ r.Equal(A, A.Atom())
+}
+
+func Test_Atom_Is(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.True(A.Is(A, B, P))
+ r.False(A.Is(B, P, Div))
+ r.False(A.Is())
+}
+
+func Test_IsAtom(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.False(IsAtom(nil, A))
+ r.True(IsAtom(A, A, B))
+ r.False(IsAtom(A, B, P))
+}
+
+func Test_Headings(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ h := Headings()
+ r.Len(h, 6)
+ r.True(h.Has(H1))
+ r.True(h.Has(H2))
+ r.True(h.Has(H3))
+ r.True(h.Has(H4))
+ r.True(h.Has(H5))
+ r.True(h.Has(H6))
+ r.False(h.Has(P))
+}
+
+func Test_Inlines(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ inl := Inlines()
+ r.True(inl.Has(A))
+ r.True(inl.Has(B))
+ r.True(inl.Has(Br))
+ r.True(inl.Has(Image))
+ r.True(inl.Has(Img))
+ r.True(inl.Has(Link))
+ r.True(inl.Has(Ref))
+ r.False(inl.Has(Div))
+}
diff --git a/binding/errors_test.go b/binding/errors_test.go
new file mode 100644
index 0000000..2425a8c
--- /dev/null
+++ b/binding/errors_test.go
@@ -0,0 +1,26 @@
+package binding
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestErrPath_Error(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ e := ErrPath("/some/path")
+ r.Contains(e.Error(), "/some/path")
+ r.Contains(e.Error(), "could not parse section from")
+}
+
+func TestErrPath_Is(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ e := ErrPath("/path1")
+ r.True(e.Is(ErrPath("/path2")))
+ r.False(e.Is(errors.New("other error")))
+}
diff --git a/blog/article_test.go b/blog/article_test.go
new file mode 100644
index 0000000..d04bbd5
--- /dev/null
+++ b/blog/article_test.go
@@ -0,0 +1,128 @@
+package blog
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestArticle_IsPublished(t *testing.T) {
+ t.Parallel()
+
+ t.Run("past date returns true", func(t *testing.T) {
+ r := require.New(t)
+ a := Article{Published: time.Now().Add(-24 * time.Hour)}
+ r.True(a.IsPublished())
+ })
+
+ t.Run("future date returns false", func(t *testing.T) {
+ r := require.New(t)
+ a := Article{Published: time.Now().Add(24 * time.Hour)}
+ r.False(a.IsPublished())
+ })
+}
+
+func TestArticle_URL(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ a := Article{Slug: "my-post"}
+ r.Equal("/my-post/", a.URL())
+}
+
+func TestArticle_FormattedDate(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ a := Article{Published: time.Date(2024, time.March, 15, 0, 0, 0, 0, time.UTC)}
+ r.Equal("March 15, 2024", a.FormattedDate())
+}
+
+func TestArticle_ISODate(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ a := Article{Published: time.Date(2024, time.March, 15, 10, 30, 0, 0, time.UTC)}
+ iso := a.ISODate()
+ r.Contains(iso, "2024-03-15")
+ r.Contains(iso, "T10:30:00")
+}
+
+func TestCalculateReadingTime(t *testing.T) {
+ t.Parallel()
+
+ t.Run("200 words is 1 minute", func(t *testing.T) {
+ r := require.New(t)
+ words := strings.Repeat("word ", 200)
+ r.Equal(1, calculateReadingTime(words))
+ })
+
+ t.Run("400 words is 2 minutes", func(t *testing.T) {
+ r := require.New(t)
+ words := strings.Repeat("word ", 400)
+ r.Equal(2, calculateReadingTime(words))
+ })
+
+ t.Run("1 word rounds up to 1 minute", func(t *testing.T) {
+ r := require.New(t)
+ r.Equal(1, calculateReadingTime("hello"))
+ })
+
+ t.Run("empty is 0 minutes", func(t *testing.T) {
+ r := require.New(t)
+ r.Equal(0, calculateReadingTime(""))
+ })
+
+ t.Run("strips HTML before counting", func(t *testing.T) {
+ r := require.New(t)
+ html := "
" + strings.Repeat("word ", 200) + "
"
+ r.Equal(1, calculateReadingTime(html))
+ })
+}
+
+func TestStripHTML(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ out string
+ }{
+ {"removes tags", "hello
", "hello"},
+ {"nested tags", "", "hello"},
+ {"preserves text", "no tags here", "no tags here"},
+ {"empty", "", ""},
+ {"self closing", "a
b", "ab"},
+ {"attributes", `link`, "link"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := require.New(t)
+ r.Equal(tt.out, stripHTML(tt.in))
+ })
+ }
+}
+
+func TestStripDetailsBlocks(t *testing.T) {
+ t.Parallel()
+
+ t.Run("removes details block", func(t *testing.T) {
+ r := require.New(t)
+ input := "beforeClick
Hidden after"
+ r.Equal("beforeafter", stripDetailsBlocks(input))
+ })
+
+ t.Run("preserves non-details content", func(t *testing.T) {
+ r := require.New(t)
+ r.Equal("hello world", stripDetailsBlocks("hello world"))
+ })
+
+ t.Run("removes multiple details blocks", func(t *testing.T) {
+ r := require.New(t)
+ input := "a1 b2 c"
+ r.Equal("abc", stripDetailsBlocks(input))
+ })
+}
diff --git a/blog/blog_test.go b/blog/blog_test.go
new file mode 100644
index 0000000..24ca510
--- /dev/null
+++ b/blog/blog_test.go
@@ -0,0 +1,37 @@
+package blog
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateOutputDir(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ root string
+ outDir string
+ wantErr bool
+ errMsg string
+ }{
+ {"valid subdirectory", "/project", "/project/public", false, ""},
+ {"empty output dir", "/project", "", true, "output directory cannot be empty"},
+ {"same as root", "/project", "/project", true, "output directory cannot be the project root"},
+ {"outside root", "/project", "/tmp/output", true, "output directory must be inside the project root"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := require.New(t)
+ err := validateOutputDir(tt.root, tt.outDir)
+ if tt.wantErr {
+ r.Error(err)
+ r.Contains(err.Error(), tt.errMsg)
+ } else {
+ r.NoError(err)
+ }
+ })
+ }
+}
diff --git a/blog/config_test.go b/blog/config_test.go
new file mode 100644
index 0000000..f9b0f56
--- /dev/null
+++ b/blog/config_test.go
@@ -0,0 +1,89 @@
+package blog
+
+import (
+ "testing"
+ "testing/fstest"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDefaultConfig(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ cfg := DefaultConfig()
+
+ r.Equal("My Blog", cfg.Title)
+ r.Equal("A blog powered by hype", cfg.Description)
+ r.Equal("suspended", cfg.Theme)
+ r.Equal("monokai", cfg.Highlight.Style)
+ r.False(cfg.Highlight.LineNumbers)
+ r.Equal("summary_large_image", cfg.SEO.TwitterCard)
+ r.Equal("content", cfg.ContentDir)
+ r.Equal("public", cfg.OutputDir)
+}
+
+func TestLoadConfig(t *testing.T) {
+ t.Parallel()
+
+ t.Run("parses config.yaml", func(t *testing.T) {
+ r := require.New(t)
+ fsys := fstest.MapFS{
+ "config.yaml": &fstest.MapFile{
+ Data: []byte("title: Test Blog\ndescription: A test\nbaseURL: https://test.com\ncontentDir: posts\noutputDir: dist\n"),
+ },
+ }
+ cfg, err := LoadConfig(fsys)
+ r.NoError(err)
+ r.Equal("Test Blog", cfg.Title)
+ r.Equal("A test", cfg.Description)
+ r.Equal("https://test.com", cfg.BaseURL)
+ r.Equal("posts", cfg.ContentDir)
+ r.Equal("dist", cfg.OutputDir)
+ })
+
+ t.Run("falls back to config.yml", func(t *testing.T) {
+ r := require.New(t)
+ fsys := fstest.MapFS{
+ "config.yml": &fstest.MapFile{
+ Data: []byte("title: YML Blog\n"),
+ },
+ }
+ cfg, err := LoadConfig(fsys)
+ r.NoError(err)
+ r.Equal("YML Blog", cfg.Title)
+ })
+
+ t.Run("missing config returns error", func(t *testing.T) {
+ r := require.New(t)
+ fsys := fstest.MapFS{}
+ _, err := LoadConfig(fsys)
+ r.Error(err)
+ r.Contains(err.Error(), "failed to read config file")
+ })
+
+ t.Run("malformed YAML returns error", func(t *testing.T) {
+ r := require.New(t)
+ fsys := fstest.MapFS{
+ "config.yaml": &fstest.MapFile{
+ Data: []byte("title: [invalid yaml\n"),
+ },
+ }
+ _, err := LoadConfig(fsys)
+ r.Error(err)
+ r.Contains(err.Error(), "failed to parse config")
+ })
+
+ t.Run("empty contentDir defaults", func(t *testing.T) {
+ r := require.New(t)
+ fsys := fstest.MapFS{
+ "config.yaml": &fstest.MapFile{
+ Data: []byte("title: Blog\ncontentDir: \"\"\noutputDir: \"\"\n"),
+ },
+ }
+ cfg, err := LoadConfig(fsys)
+ r.NoError(err)
+ r.Equal("content", cfg.ContentDir)
+ r.Equal("public", cfg.OutputDir)
+ })
+}
diff --git a/blog/highlight_test.go b/blog/highlight_test.go
new file mode 100644
index 0000000..9ae39cb
--- /dev/null
+++ b/blog/highlight_test.go
@@ -0,0 +1,112 @@
+package blog
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewHighlighter(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ h := NewHighlighter("", false)
+ r.NotNil(h)
+ r.Equal("monokai", h.style)
+ r.False(h.lineNumbers)
+
+ h2 := NewHighlighter("dracula", true)
+ r.Equal("dracula", h2.style)
+ r.True(h2.lineNumbers)
+}
+
+func TestHighlighter_Highlight(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ h := NewHighlighter("monokai", false)
+
+ t.Run("go code", func(t *testing.T) {
+ result, err := h.Highlight("fmt.Println(\"hello\")", "go")
+ r.NoError(err)
+ r.NotEmpty(result)
+ r.Contains(result, "chroma")
+ })
+
+ t.Run("unknown language uses fallback", func(t *testing.T) {
+ result, err := h.Highlight("some code", "nonexistentlang")
+ r.NoError(err)
+ r.NotEmpty(result)
+ })
+
+ t.Run("empty code", func(t *testing.T) {
+ result, err := h.Highlight("", "go")
+ r.NoError(err)
+ r.NotNil(result)
+ })
+}
+
+func TestHighlighter_CSS(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ h := NewHighlighter("monokai", false)
+ css := h.CSS()
+ r.NotEmpty(css)
+ r.Contains(css, "chroma")
+}
+
+func TestGetLexer(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ }{
+ {"templ maps to html", "templ"},
+ {"sh maps to bash", "sh"},
+ {"dockerfile maps to docker", "dockerfile"},
+ {"mod maps to gomod", "mod"},
+ {"yml maps to yaml", "yml"},
+ {"rb maps to ruby", "rb"},
+ {"py maps to python", "py"},
+ {"js maps to javascript", "js"},
+ {"ts maps to typescript", "ts"},
+ {"dot prefix stripped", ".go"},
+ {"direct language", "go"},
+ {"unknown returns fallback", "zzz_unknown"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := require.New(t)
+ lexer := getLexer(tt.input)
+ r.NotNil(lexer)
+ })
+ }
+}
+
+func TestEscapeHTML(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ out string
+ }{
+ {"ampersand", "a&b", "a&b"},
+ {"less than", "ab", "a>b"},
+ {"quote", `a"b`, "a"b"},
+ {"empty", "", ""},
+ {"no special chars", "hello", "hello"},
+ {"all special", `&<>"`, "&<>""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := require.New(t)
+ r.Equal(tt.out, escapeHTML(tt.in))
+ })
+ }
+}
diff --git a/blog/seo_test.go b/blog/seo_test.go
index dddc1f1..08bd95f 100644
--- a/blog/seo_test.go
+++ b/blog/seo_test.go
@@ -11,6 +11,142 @@ import (
"github.com/stretchr/testify/require"
)
+func TestSEO_OpenGraphTags(t *testing.T) {
+ t.Parallel()
+
+ t.Run("full struct", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{
+ Title: "My Title",
+ Description: "A description",
+ URL: "https://example.com",
+ Image: "https://example.com/img.png",
+ Type: "article",
+ }
+ tags := s.OpenGraphTags()
+ r.Contains(tags, `og:title`)
+ r.Contains(tags, `My Title`)
+ r.Contains(tags, `og:description`)
+ r.Contains(tags, `og:url`)
+ r.Contains(tags, `og:image`)
+ r.Contains(tags, `og:type`)
+ r.Contains(tags, `article`)
+ })
+
+ t.Run("minimal struct defaults type to website", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{Title: "Title Only"}
+ tags := s.OpenGraphTags()
+ r.Contains(tags, `og:title`)
+ r.Contains(tags, `website`)
+ r.NotContains(tags, `og:description`)
+ r.NotContains(tags, `og:url`)
+ r.NotContains(tags, `og:image`)
+ })
+
+ t.Run("escapes special characters", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{Title: `Title & "quotes"`}
+ tags := s.OpenGraphTags()
+ r.Contains(tags, `Title & "quotes"`)
+ })
+}
+
+func TestSEO_TwitterCardTags(t *testing.T) {
+ t.Parallel()
+
+ t.Run("full struct", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{
+ Title: "My Title",
+ Description: "A description",
+ Image: "https://example.com/img.png",
+ TwitterCard: "summary",
+ TwitterSite: "@example",
+ }
+ tags := s.TwitterCardTags()
+ r.Contains(tags, `twitter:card`)
+ r.Contains(tags, `summary`)
+ r.Contains(tags, `twitter:title`)
+ r.Contains(tags, `twitter:description`)
+ r.Contains(tags, `twitter:image`)
+ r.Contains(tags, `twitter:site`)
+ r.Contains(tags, `@example`)
+ })
+
+ t.Run("defaults card to summary_large_image", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{Title: "Title"}
+ tags := s.TwitterCardTags()
+ r.Contains(tags, `summary_large_image`)
+ })
+
+ t.Run("omits empty fields", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{Title: "Title"}
+ tags := s.TwitterCardTags()
+ r.NotContains(tags, `twitter:description`)
+ r.NotContains(tags, `twitter:image`)
+ r.NotContains(tags, `twitter:site`)
+ })
+}
+
+func TestSEO_JSONLD(t *testing.T) {
+ t.Parallel()
+
+ t.Run("returns empty for non-article", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{Title: "Title", Type: "website"}
+ r.Empty(s.JSONLD())
+ })
+
+ t.Run("returns empty when type is empty", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{Title: "Title"}
+ r.Empty(s.JSONLD())
+ })
+
+ t.Run("returns script tag for article", func(t *testing.T) {
+ r := require.New(t)
+ s := SEO{
+ Title: "My Article",
+ Type: "article",
+ Author: "John Doe",
+ Published: "2024-01-15",
+ }
+ jsonld := s.JSONLD()
+ r.Contains(jsonld, `application/ld+json`)
+ r.Contains(jsonld, `"My Article"`)
+ r.Contains(jsonld, `"John Doe"`)
+ r.Contains(jsonld, `"2024-01-15"`)
+ r.Contains(jsonld, `schema.org`)
+ })
+}
+
+func TestEscapeAttr(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ out string
+ }{
+ {"ampersand", "a&b", "a&b"},
+ {"quote", `a"b`, "a"b"},
+ {"less than", "ab", "a>b"},
+ {"empty", "", ""},
+ {"no special chars", "hello world", "hello world"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := require.New(t)
+ r.Equal(tt.out, escapeAttr(tt.in))
+ })
+ }
+}
+
func renderSEOPartial(t *testing.T, data PageData) string {
t.Helper()
diff --git a/blog/templates_test.go b/blog/templates_test.go
new file mode 100644
index 0000000..1e98a4d
--- /dev/null
+++ b/blog/templates_test.go
@@ -0,0 +1,81 @@
+package blog
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsBuiltinTheme(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ in string
+ want bool
+ }{
+ {"suspended", "suspended", true},
+ {"developer", "developer", true},
+ {"cards", "cards", true},
+ {"unknown", "unknown", false},
+ {"empty", "", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := require.New(t)
+ r.Equal(tt.want, IsBuiltinTheme(tt.in))
+ })
+ }
+}
+
+func TestListBuiltinThemes(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ themes := ListBuiltinThemes()
+ r.Len(themes, 3)
+ r.Contains(themes, "suspended")
+ r.Contains(themes, "developer")
+ r.Contains(themes, "cards")
+
+ themes[0] = "modified"
+ r.NotEqual("modified", ListBuiltinThemes()[0])
+}
+
+func TestParseYAML(t *testing.T) {
+ t.Parallel()
+
+ t.Run("valid theme YAML", func(t *testing.T) {
+ r := require.New(t)
+ data := []byte("name: My Theme\ndescription: A theme\nauthor: Test\nversion: 1.0\nrepository: https://example.com\npreview: https://preview.com\n")
+ info := &ThemeInfo{}
+ err := parseYAML(data, info)
+ r.NoError(err)
+ r.Equal("My Theme", info.Name)
+ r.Equal("A theme", info.Description)
+ r.Equal("Test", info.Author)
+ r.Equal("1.0", info.Version)
+ r.Equal("https://example.com", info.Repository)
+ r.Equal("https://preview.com", info.Preview)
+ })
+
+ t.Run("skips comments and blank lines", func(t *testing.T) {
+ r := require.New(t)
+ data := []byte("# comment\n\nname: Theme\n")
+ info := &ThemeInfo{}
+ err := parseYAML(data, info)
+ r.NoError(err)
+ r.Equal("Theme", info.Name)
+ })
+
+ t.Run("handles quoted values", func(t *testing.T) {
+ r := require.New(t)
+ data := []byte("name: \"Quoted Theme\"\nauthor: 'Single Quoted'\n")
+ info := &ThemeInfo{}
+ err := parseYAML(data, info)
+ r.NoError(err)
+ r.Equal("Quoted Theme", info.Name)
+ r.Equal("Single Quoted", info.Author)
+ })
+}
diff --git a/errors_test.go b/errors_test.go
new file mode 100644
index 0000000..888fbdd
--- /dev/null
+++ b/errors_test.go
@@ -0,0 +1,86 @@
+package hype
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestErrIsNil_Error(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ e := ErrIsNil("parser")
+ r.Equal("parser is nil", e.Error())
+}
+
+func TestWrapNodeErr(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil error returns nil", func(t *testing.T) {
+ r := require.New(t)
+ r.NoError(WrapNodeErr(Text("x"), nil))
+ })
+
+ t.Run("wraps non-Tag node with type", func(t *testing.T) {
+ r := require.New(t)
+ err := WrapNodeErr(Text("x"), errors.New("boom"))
+ r.Error(err)
+ r.Contains(err.Error(), "hype.Text")
+ r.Contains(err.Error(), "boom")
+ })
+}
+
+type jsonErr struct {
+ Msg string `json:"msg"`
+}
+
+func (e jsonErr) Error() string { return e.Msg }
+func (e jsonErr) MarshalJSON() ([]byte, error) {
+ return json.Marshal(map[string]string{"msg": e.Msg})
+}
+
+func TestErrForJSON(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil returns nil", func(t *testing.T) {
+ r := require.New(t)
+ r.Nil(errForJSON(nil))
+ })
+
+ t.Run("json.Marshaler returned as-is", func(t *testing.T) {
+ r := require.New(t)
+ je := jsonErr{Msg: "test"}
+ result := errForJSON(je)
+ r.Equal(je, result)
+ })
+
+ t.Run("regular error returns string", func(t *testing.T) {
+ r := require.New(t)
+ result := errForJSON(fmt.Errorf("simple"))
+ r.Equal("simple", result)
+ })
+}
+
+func TestToError(t *testing.T) {
+ t.Parallel()
+
+ t.Run("nil returns empty string", func(t *testing.T) {
+ r := require.New(t)
+ r.Empty(toError(nil))
+ })
+
+ t.Run("json.Marshaler returns JSON", func(t *testing.T) {
+ r := require.New(t)
+ result := toError(jsonErr{Msg: "test"})
+ r.Contains(result, "test")
+ })
+
+ t.Run("regular error returns Error()", func(t *testing.T) {
+ r := require.New(t)
+ r.Equal("boom", toError(fmt.Errorf("boom")))
+ })
+}
diff --git a/text_test.go b/text_test.go
new file mode 100644
index 0000000..6d0d245
--- /dev/null
+++ b/text_test.go
@@ -0,0 +1,54 @@
+package hype
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestText_MarshalJSON(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ tn := Text("hello")
+ data, err := tn.MarshalJSON()
+ r.NoError(err)
+
+ var m map[string]any
+ r.NoError(json.Unmarshal(data, &m))
+ r.Equal("hello", m["text"])
+ r.Contains(m["type"], "Text")
+}
+
+func TestText_Children(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ tn := Text("hello")
+ r.Empty(tn.Children())
+}
+
+func TestText_String(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.Equal("hello", Text("hello").String())
+ r.Equal("", Text("").String())
+}
+
+func TestText_MD(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.Equal("hello", Text("hello").MD())
+}
+
+func TestText_IsEmptyNode(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.True(Text("").IsEmptyNode())
+ r.True(Text(" \t\n").IsEmptyNode())
+ r.False(Text("hello").IsEmptyNode())
+}
diff --git a/type_test.go b/type_test.go
new file mode 100644
index 0000000..d6572f2
--- /dev/null
+++ b/type_test.go
@@ -0,0 +1,19 @@
+package hype
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestToType(t *testing.T) {
+ t.Parallel()
+ r := require.New(t)
+
+ r.Equal("hype.Text", toType(Text("x")))
+
+ p := &Element{}
+ r.Equal("hype.Element", toType(p))
+
+ r.Equal("string", toType("hello"))
+}