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

", "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 := "before
ClickHidden
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 := "a
1
b
2
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")) +}