diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 65b45f1..fdcc268 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,3 @@ # These are supported funding model platforms -github: markbates -patreon: buffalo +github: gopherguides diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee13bad..94fbe70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,22 +1,66 @@ name: Tests -on: [push] -jobs: - tests-on: - name: ${{matrix.go-version}} ${{matrix.os}} +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + name: Test Go ${{matrix.go-version}} on ${{matrix.os}} runs-on: ${{ matrix.os }} strategy: matrix: - go-version: [1.14.x, 1.15.x] - os: [macos-latest, ubuntu-latest] + go-version: ['1.24.x'] + os: [ubuntu-latest, macos-latest, windows-latest] + steps: - - name: Checkout Code - uses: actions/checkout@v1 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 with: - fetch-depth: 1 - - name: Test + go-version: ${{ matrix.go-version }} + cache-dependency-path: go.sum + + - name: Verify Go version + run: go version + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Run tests + run: go test -race . ./cmd/remark ./cmd/retoc ./htm ./md + + - name: Check formatting run: | - go mod tidy -v - go test -race ./... + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "Code is not formatted. Please run 'gofmt -s -w .'" + gofmt -s -l . + exit 1 + fi + if: runner.os != 'Windows' + + - name: Run go vet + run: go vet ./... + + - name: Generate coverage report + run: | + mkdir -p .coverage + go test -coverprofile=.coverage/coverage.out . ./cmd/remark ./cmd/retoc ./htm ./md + if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24.x' + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./.coverage/coverage.out + flags: unittests + name: codecov-umbrella + if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24.x' diff --git a/.gitignore b/.gitignore index 87a74ab..1bb314d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ gin-bin .idea/ .vscode cover.out +coverage.out +.coverage/ diff --git a/README.md b/README.md index 38a075d..e7f17c9 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,542 @@ -# remark +# 🎯 Remark - Advanced Markdown Processing & HTML Generation -[![](https://github.com/gopherguides/remark/workflows/Tests/badge.svg)](https://github.com/gopherguides/remark/actions) +[![Tests](https://github.com/gopherguides/remark/workflows/Tests/badge.svg)](https://github.com/gopherguides/remark/actions) +[![codecov](https://codecov.io/gh/gopherguides/remark/branch/main/graph/badge.svg)](https://codecov.io/gh/gopherguides/remark) [![GoDoc](https://godoc.org/github.com/gopherguides/remark?status.svg)](https://godoc.org/github.com/gopherguides/remark) +[![Go Report Card](https://goreportcard.com/badge/github.com/gopherguides/remark)](https://goreportcard.com/report/github.com/gopherguides/remark) -### Requirements +**Remark** is a powerful Go package that transforms markdown into structured, +processable content. Built for educational platforms, documentation systems, and +content management, it provides a sophisticated tag-based architecture for +parsing, processing, and rendering markdown with advanced features like file +inclusion, code syntax highlighting, metadata extraction, and HTML generation. -* Go 1.13+ -* Go Modules +## 🚀 Features -### Installation +### Core Capabilities + +- **Dual Parser Support**: Process both Markdown (`md`) and HTML (`htm`) content +- **Tag-Based Architecture**: Structured content representation through a + unified `Tag` interface +- **File Inclusion**: Dynamic content composition with `` tags +- **Advanced Code Blocks**: Syntax highlighting with snippet support and + language detection +- **Metadata Extraction**: Parse and process document metadata from HTML + details tags +- **Section Management**: Automatic content sectioning with horizontal rule delimiters +- **HTML Generation**: Convert processed content to clean HTML output +- **Table of Contents**: Generate structured TOCs from heading hierarchies + +### Advanced Features + +- **Template Processing**: Go template integration for dynamic content generation +- **Image Processing**: Automatic image validation and path resolution +- **Link Processing**: Smart link handling and validation +- **Attribute Management**: Rich attribute system for all content elements +- **Custom Printers**: Extensible rendering system with custom tag processors +- **Snippet Management**: Advanced code snippet extraction and processing + +## 📦 Installation + +### Package Installation ```bash -$ go get github.com/gopherguides/remark/cmd/remark +go get github.com/gopherguides/remark +``` + +### CLI Tools + +```bash +# Install the remark CLI processor +go install github.com/gopherguides/remark/cmd/remark@latest + +# Install the table of contents generator +go install github.com/gopherguides/remark/cmd/retoc@latest +``` + +## 🎮 Quick Start + +### Basic Markdown Processing + +```go +package main + +import ( + "fmt" + "github.com/gopherguides/remark/md" +) + +func main() { + markdown := `# Hello World + +This is a **markdown** document with: +- Lists +- Code blocks +- And more! + +## Code Example + +` + "```go" + ` +func main() { + fmt.Println("Hello, World!") +} +` + "```" + ` +` + + // Parse the markdown + tags, err := md.Parse(".", []byte(markdown)) + if err != nil { + panic(err) + } + + // Output the processed content + fmt.Println(tags) +} +``` + +### Advanced Processing with File Inclusion + +```markdown +# My Course Module + +Welcome to the course! + +--- + + + +--- + + +``` + +### HTML Processing + +```go +package main + +import ( + "fmt" + "github.com/gopherguides/remark/htm" +) + +func main() { + html := `
+

Course Title

+
+ course: advanced-go + difficulty: intermediate +
+ +
` + + // Parse HTML content + doc, err := htm.Parse([]byte(html)) + if err != nil { + panic(err) + } + + // Access metadata + metadata := doc.Metadata() + fmt.Printf("Course: %s\n", metadata["course"]) + fmt.Printf("Difficulty: %s\n", metadata["difficulty"]) +} +``` + +## 🛠️ CLI Tools + +### remark - Markdown Processor + +Process markdown from stdin and output structured content: + +```bash +# Process a markdown file +cat document.md | remark + +# Set working directory for includes +MARKED_ORIGIN=/path/to/content cat document.md | remark +``` + +### retoc - Table of Contents Generator + +Generate structured table of contents from markdown files: + +```bash +# Generate TOC for specific files +retoc /path/to/content/ + +# Example output: +# Course Introduction +# Getting Started +# Requirements +# System Requirements +# Software Installation +# First Steps +``` + +## 🏗️ Architecture + +### Tag System + +Remark uses a unified `Tag` interface for all content elements: + +```go +type Tag interface { + Attrs() Attributes + GetChildren() Tags + Options() tags.Options + TagName() string + fmt.Stringer +} +``` + +### Core Components + +#### 1. **Generic Tags** + +Universal container for any HTML-like element: + +```go +generic := remark.NewGeneric("div") +generic.Set("class", "content") +generic.Append(remark.String("Hello World")) +``` + +#### 2. **Headings** + +Structured heading elements with level information: + +```go +type Heading struct { + *Generic + Level int // 1-6 for h1-h6 +} +``` + +#### 3. **Code Blocks** + +Advanced code processing with syntax highlighting: + +```go +type CodeBlock struct { + *Generic + Language string + Snippets Snippets +} +``` + +#### 4. **Sections** + +Document sections with metadata support: + +```go +type Section struct { + *Generic + Title string +} +``` + +### Parsers + +#### Markdown Parser (`md`) + +- Full CommonMark compliance +- Extended syntax support (tables, strikethrough, etc.) +- Template processing +- File inclusion +- Custom extension support + +#### HTML Parser (`htm`) + +- Clean HTML parsing +- Metadata extraction from `
` tags +- Image validation +- Custom tag processing + +## 🎯 Use Cases + +### Educational Content Management + +Perfect for course materials, tutorials, and documentation: + +```markdown +# Week 1: Introduction to Go + +
+overview: true +difficulty: beginner +duration: 2 hours +
+ +Welcome to our Go programming course! + + + +## Your First Program + + +``` + +### Documentation Systems + +Build comprehensive documentation with cross-references: + +```markdown +# API Documentation + + + + + + +``` + +### Content Publishing + +Create rich content with embedded examples: + +```markdown +# Tutorial: Building a Web Server + + + +The code above shows... + + +``` + +## 🔧 Advanced Features + +### Custom Printers + +Create custom rendering logic: + +```go +printer := &htm.Printer{} + +// Custom code block renderer +printer.Set("code", func(t remark.Tag) (string, error) { + code := t.(*remark.CodeBlock) + return fmt.Sprintf(`
%s
`, + html.EscapeString(code.Children.String())), nil +}) + +// Render with custom logic +html, err := printer.Print(tags...) +``` + +### Metadata Processing + +Extract and use document metadata: + +```go +// Find sections with overview metadata +for _, tag := range tags { + if section, ok := tag.(*md.Section); ok { + if section.Overview() { + overview := tags.Overview() // Get overview text + fmt.Printf("Overview: %s\n", overview) + } + } +} +``` + +### Snippet Management + +Process code snippets with markers: + +```go +// In your Go file: +// snippet:start:basic-server +func main() { + http.HandleFunc("/", handler) + log.Fatal(http.ListenAndServe(":8080", nil)) +} +// snippet:end:basic-server +``` + +```markdown + +``` + +## 🤝 Integration with Hype + +Remark is designed to work seamlessly with the +[Hype](https://github.com/gopherguides/hype) package for advanced content +generation and templating. Together, they form a powerful content processing +pipeline for educational platforms and documentation systems. + +## 📚 API Reference + +### Core Functions + +#### Markdown Processing + +```go +// Parse markdown from bytes +md.Parse(root string, src []byte) (remark.Tags, error) + +// Parse markdown from file +md.ParseFile(filename string) (remark.Tags, error) + +// Create new parser +md.NewParser(root string) *Parser ``` +#### HTML API Functions + +```go +// Parse HTML content +htm.Parse(src []byte) (*Document, error) + +// Create new HTML parser +htm.NewParser(root string) *Parser + +// Print tags to HTML +htm.Print(tags ...remark.Tag) (string, error) +``` + +#### Tag Operations + +```go +// Find specific tags +tags.FindFirst(name string) (Tag, bool) +tags.FindAll(name string) Tags + +// Get document body +tags.Body() (Tag, bool) + +// Extract overview +tags.Overview() string +``` + +### Interfaces + +#### Essential Interfaces + +```go +// Core tag interface +type Tag interface { + Attrs() Attributes + GetChildren() Tags + Options() tags.Options + TagName() string + fmt.Stringer +} + +// Metadata support +type Metadatable interface { + Tag + Metadata() Metadata +} + +// Appendable content +type Appendable interface { + Tag + Append(tag Tag) +} +``` + +## 🎨 Examples + +### Complete Processing Pipeline + +```go +func processContentDirectory(dir string) error { + return filepath.Walk(dir, func(path string, info os.FileInfo, + err error) error { + if filepath.Ext(path) != ".md" { + return nil + } + + // Parse the markdown file + tags, err := md.ParseFile(path) + if err != nil { + return err + } + + // Process each section + for _, tag := range tags { + if section, ok := tag.(*md.Section); ok { + fmt.Printf("Section: %s\n", section.Title) + + // Extract metadata + metadata := section.Metadata() + if difficulty, ok := metadata["difficulty"]; ok { + fmt.Printf("Difficulty: %s\n", difficulty) + } + + // Find code blocks + codeBlocks := section.GetChildren().FindAll("code") + fmt.Printf("Code blocks: %d\n", len(codeBlocks)) + } + } + + return nil + }) +} +``` + +### Custom Content Processor + +```go +type CourseProcessor struct { + parser *md.Parser +} + +func (cp *CourseProcessor) ProcessCourse(content []byte) (*Course, error) { + tags, err := cp.parser.Parse(content) + if err != nil { + return nil, err + } + + course := &Course{ + Modules: make([]Module, 0), + } + + for _, tag := range tags { + if section, ok := tag.(*md.Section); ok { + module := Module{ + Title: section.Title, + Content: section.GetChildren().String(), + } + + // Extract difficulty and duration + metadata := section.Metadata() + module.Difficulty = metadata["difficulty"] + module.Duration = metadata["duration"] + + course.Modules = append(course.Modules, module) + } + } + + return course, nil +} +``` + +## 🔄 Requirements + +- **Go**: 1.24 or higher +- **Go Modules**: Required for dependency management + +## 📝 License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) +file for details. + +## 🤝 Contributing + +We welcome contributions! Please see our contributing guidelines and feel free +to submit issues or pull requests. + +## 🎯 Built For + +- **Educational Platforms**: Course content management and delivery +- **Documentation Systems**: Technical documentation with code examples +- **Content Management**: Rich content processing and publishing +- **Static Site Generation**: Advanced markdown processing for websites + --- + +**Remark** - Transform your markdown into structured, powerful content. Built +with ❤️ by the [Gopher Guides](https://github.com/gopherguides) team. diff --git a/attributes_test.go b/attributes_test.go new file mode 100644 index 0000000..7488974 --- /dev/null +++ b/attributes_test.go @@ -0,0 +1,102 @@ +package remark + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Attributes_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + attrs := Attributes{ + "class": "test", + "id": "myid", + } + + result := attrs.String() + // JSON marshaling may order keys differently, so we check it's valid JSON + r.Contains(result, `"class":"test"`) + r.Contains(result, `"id":"myid"`) + r.Contains(result, `{`) + r.Contains(result, `}`) +} + +func Test_Attributes_Attrs(t *testing.T) { + t.Parallel() + r := require.New(t) + + attrs := Attributes{ + "class": "test", + "id": "myid", + } + + result := attrs.Attrs() + r.Equal(attrs, result) +} + +func Test_Attributes_Add(t *testing.T) { + t.Parallel() + r := require.New(t) + + t.Run("add to empty attributes", func(t *testing.T) { + attrs := Attributes{} + attrs.Add("class", "test") + r.Equal("test", attrs["class"]) + }) + + t.Run("add to existing attribute", func(t *testing.T) { + attrs := Attributes{ + "class": "existing", + } + attrs.Add("class", "new") + r.Equal("existing new", attrs["class"]) + }) + + t.Run("add to non-existing attribute", func(t *testing.T) { + attrs := Attributes{ + "id": "test", + } + attrs.Add("class", "new") + r.Equal(" new", attrs["class"]) // Add() joins with space, so empty + "new" = " new" + r.Equal("test", attrs["id"]) + }) +} + +func Test_Attributes_Get(t *testing.T) { + t.Parallel() + r := require.New(t) + + attrs := Attributes{ + "class": "test", + "id": "myid", + } + + t.Run("get existing attribute", func(t *testing.T) { + value, ok := attrs.Get("class") + r.True(ok) + r.Equal("test", value) + }) + + t.Run("get non-existing attribute", func(t *testing.T) { + value, ok := attrs.Get("nonexistent") + r.False(ok) + r.Equal("", value) + }) +} + +func Test_Attributes_Options(t *testing.T) { + t.Parallel() + r := require.New(t) + + attrs := Attributes{ + "class": "test", + "id": "myid", + } + + opts := attrs.Options() + r.Equal("test", opts["class"]) + r.Equal("myid", opts["id"]) + r.Len(opts, 2) +} diff --git a/code_block_test.go b/code_block_test.go index e2610e9..f471af6 100644 --- a/code_block_test.go +++ b/code_block_test.go @@ -38,3 +38,31 @@ func Test_CodeBlock_HTML(t *testing.T) { r.Equal(exp, act) } + +func Test_CodeBlock_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + code := CodeBlock{ + Generic: &Generic{ + Attributes: Attributes{ + "src": "./src/foo.go", + "snippet": "snip", + }, + Children: Tags{ + String("fmt.Println(\"Hello\")"), + }, + }, + } + + result := code.String() + + // String() should return unescaped HTML + r.Contains(result, "
")
+	r.Contains(result, "")
+	r.Contains(result, "
") +} diff --git a/code_test.go b/code_test.go new file mode 100644 index 0000000..5e2efeb --- /dev/null +++ b/code_test.go @@ -0,0 +1,86 @@ +package remark + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Code_Name(t *testing.T) { + t.Parallel() + r := require.New(t) + + code := Code{ + Generic: &Generic{}, + } + + r.Equal("code", code.Name()) +} + +func Test_Code_HTML(t *testing.T) { + t.Parallel() + r := require.New(t) + + code := Code{ + Generic: &Generic{ + Attributes: Attributes{ + "class": "highlight", + "id": "test-code", + }, + Children: Tags{ + String("fmt.Println(\"Hello, World!\")"), + }, + }, + } + + html := code.HTML() + htmlStr := string(html) + + r.Contains(htmlStr, ``) +} + +func Test_Code_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + code := Code{ + Generic: &Generic{ + Attributes: Attributes{ + "class": "highlight", + }, + Children: Tags{ + String("fmt.Println("Hello")"), + }, + }, + } + + result := code.String() + // String() should unescape HTML entities + r.Contains(result, `fmt.Println("Hello")`) + r.Contains(result, ``) +} diff --git a/generic_test.go b/generic_test.go new file mode 100644 index 0000000..30b5337 --- /dev/null +++ b/generic_test.go @@ -0,0 +1,125 @@ +package remark + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_Generic_Append(t *testing.T) { + t.Parallel() + r := require.New(t) + + generic := NewGeneric("div") + child := String("test content") + + generic.Append(child) + + r.Len(generic.Children, 1) + r.Equal(child, generic.Children[0]) +} + +func Test_Generic_GetChildren(t *testing.T) { + t.Parallel() + r := require.New(t) + + generic := NewGeneric("div") + child1 := String("test1") + child2 := String("test2") + + generic.Children = append(generic.Children, child1, child2) + + children := generic.GetChildren() + r.Len(children, 2) + r.Equal(child1, children[0]) + r.Equal(child2, children[1]) +} + +func Test_Generic_TagName(t *testing.T) { + t.Parallel() + r := require.New(t) + + generic := NewGeneric("div") + r.Equal("div", generic.TagName()) +} + +func Test_Generic_Metadata(t *testing.T) { + t.Parallel() + r := require.New(t) + + generic := NewGeneric("div") + + // Test initial metadata + metadata := generic.Metadata() + r.NotNil(metadata) + r.Equal(Metadata{}, metadata) + + // Test that it initializes if nil + generic.Data = nil + metadata = generic.Metadata() + r.NotNil(metadata) + r.Equal(Metadata{}, metadata) +} + +func Test_Generic_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + t.Run("with name and children", func(t *testing.T) { + generic := NewGeneric("div") + generic.Set("class", "container") + generic.Children = append(generic.Children, String("Hello World")) + + result := generic.String() + r.Contains(result, "") + }) + + t.Run("with name but no children", func(t *testing.T) { + generic := NewGeneric("br") + generic.Set("class", "break") + + result := generic.String() + r.Contains(result, "") + r.Contains(result, "Hello & World", + File: "index.html", + Language: "html", + Name: "greeting", + Start: 1, + End: 1, + } + + html := snippet.HTML() + r.Equal(template.HTML("
Hello & World
"), html) +} + +func Test_Snippet_Fields(t *testing.T) { + t.Parallel() + r := require.New(t) + + snippet := Snippet{ + Content: "package main", + File: "/path/to/main.go", + Language: "golang", + Name: "package_declaration", + Start: 1, + End: 1, + } + + r.Equal("package main", snippet.Content) + r.Equal("/path/to/main.go", snippet.File) + r.Equal("golang", snippet.Language) + r.Equal("package_declaration", snippet.Name) + r.Equal(1, snippet.Start) + r.Equal(1, snippet.End) +} + +func Test_Snippets_Map(t *testing.T) { + t.Parallel() + r := require.New(t) + + snippets := Snippets{ + "hello": Snippet{ + Content: "fmt.Println(\"Hello\")", + Language: "go", + Name: "hello", + }, + "world": Snippet{ + Content: "fmt.Println(\"World\")", + Language: "go", + Name: "world", + }, + } + + r.Len(snippets, 2) + + hello, exists := snippets["hello"] + r.True(exists) + r.Equal("fmt.Println(\"Hello\")", hello.Content) + r.Equal("hello", hello.Name) + + world, exists := snippets["world"] + r.True(exists) + r.Equal("fmt.Println(\"World\")", world.Content) + r.Equal("world", world.Name) +} + +func Test_Snippets_Empty(t *testing.T) { + t.Parallel() + r := require.New(t) + + snippets := Snippets{} + r.Len(snippets, 0) + + _, exists := snippets["nonexistent"] + r.False(exists) +} diff --git a/string_test.go b/string_test.go new file mode 100644 index 0000000..7790d26 --- /dev/null +++ b/string_test.go @@ -0,0 +1,95 @@ +package remark + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_String_Options(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("test content") + opts := s.Options() + + r.NotNil(opts) + r.Len(opts, 0) +} + +func Test_String_Attrs(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("test content") + attrs := s.Attrs() + + r.NotNil(attrs) + r.Len(attrs, 0) + r.Equal(Attributes{}, attrs) +} + +func Test_String_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("test content") + result := s.String() + + r.Equal("test content", result) +} + +func Test_String_TagName(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("test content") + name := s.TagName() + + r.Equal("string", name) +} + +func Test_String_GetChildren(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("test content") + children := s.GetChildren() + + r.NotNil(children) + r.Len(children, 0) + r.Equal(Tags{}, children) +} + +func Test_String_AsTag(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("test content") + + // Test that String implements Tag interface + var tag Tag = s + r.NotNil(tag) + r.Equal("string", tag.TagName()) + r.Equal("test content", tag.String()) +} + +func Test_String_Empty(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("") + + r.Equal("", s.String()) + r.Equal("string", s.TagName()) + r.Len(s.GetChildren(), 0) +} + +func Test_String_WithSpecialChars(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := String("test <>&\"' content") + + r.Equal("test <>&\"' content", s.String()) +} diff --git a/tag_test.go b/tag_test.go index 8f9cd5d..8bd5b7b 100644 --- a/tag_test.go +++ b/tag_test.go @@ -23,3 +23,120 @@ func Test_Tags_Body(t *testing.T) { r.True(ok) r.NotNil(b) } + +func Test_Tags_Overview(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a tag with metadata that has overview + section := NewGeneric("section") + section.Data = Metadata{"overview": "true"} + + // Add some children including heading and string content + heading := &Heading{ + Generic: NewGeneric("h1"), + Level: 1, + } + heading.Generic.Children = append(heading.Generic.Children, String("Title")) + + section.Children = append(section.Children, heading) + section.Children = append(section.Children, String("This is overview content.")) + section.Children = append(section.Children, String("More content here.")) + + tags := Tags{section} + overview := tags.Overview() + + r.Contains(overview, "This is overview content.") + r.Contains(overview, "More content here.") + r.NotContains(overview, "Title") // Headings should be excluded +} + +func Test_Tags_Overview_NoOverview(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create tags without overview metadata + section := NewGeneric("section") + section.Children = append(section.Children, String("Regular content")) + + tags := Tags{section} + overview := tags.Overview() + + r.Equal("", overview) +} + +func Test_Tags_String(t *testing.T) { + t.Parallel() + r := require.New(t) + + tags := Tags{ + String("First content"), + String("Second content"), + NewGeneric("div"), + } + + result := tags.String() + expected := "First content Second content
" + r.Equal(expected, result) +} + +func Test_Tags_String_Empty(t *testing.T) { + t.Parallel() + r := require.New(t) + + tags := Tags{} + result := tags.String() + r.Equal("", result) +} + +func Test_Tags_FindFirst(t *testing.T) { + t.Parallel() + r := require.New(t) + + div := NewGeneric("div") + span := NewGeneric("span") + div.Children = append(div.Children, span) + + tags := Tags{div} + + // Test finding existing tag + found, ok := tags.FindFirst("span") + r.True(ok) + r.Equal(span, found) + + // Test finding non-existing tag + notFound, ok := tags.FindFirst("nonexistent") + r.False(ok) + r.Nil(notFound) +} + +func Test_Tags_FindAll(t *testing.T) { + t.Parallel() + r := require.New(t) + + div1 := NewGeneric("div") + div2 := NewGeneric("div") + span1 := NewGeneric("span") + span2 := NewGeneric("span") + + div1.Children = append(div1.Children, span1) + div2.Children = append(div2.Children, span2) + + tags := Tags{div1, div2} + + // Test finding all divs + divs := tags.FindAll("div") + r.Len(divs, 2) + r.Contains(divs, div1) + r.Contains(divs, div2) + + // Test finding all spans (nested) + spans := tags.FindAll("span") + r.Len(spans, 2) + r.Contains(spans, span1) + r.Contains(spans, span2) + + // Test finding non-existing tags + notFound := tags.FindAll("nonexistent") + r.Len(notFound, 0) +}