diff --git a/README.md b/README.md index 65108b6..281cdf3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A modern HTTP(S) client for the command line. ## Features -- **Response formatting** - Automatic formatting and syntax highlighting for JSON, XML, YAML, HTML, CSS, CSV, MessagePack, Protocol Buffers, and more +- **Response formatting** - Automatic formatting and syntax highlighting for JSON, XML, YAML, HTML, CSS, CSV, Markdown, MessagePack, Protocol Buffers, and more - **Image rendering** - Display images directly in your terminal - **WebSocket support** - Bidirectional WebSocket connections with automatic JSON formatting - **gRPC support** - Make gRPC calls with automatic JSON-to-protobuf conversion diff --git a/docs/output-formatting.md b/docs/output-formatting.md index 7724cfa..a98b6e6 100644 --- a/docs/output-formatting.md +++ b/docs/output-formatting.md @@ -137,6 +137,20 @@ Features: fetch example.com/styles.css ``` +### Markdown + +**Content-Types**: `text/markdown`, `text/x-markdown` + +Features: + +- Syntax highlighting for headings, bold, italic, code spans, links, images +- Fenced code block delegation to JSON, YAML, XML, HTML, CSS formatters +- Blockquote and list marker highlighting + +```sh +fetch example.com/README.md +``` + ### CSV **Content-Types**: `text/csv`, `application/csv` diff --git a/go.mod b/go.mod index d6276b8..f8c5d9b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/mattn/go-runewidth v0.0.19 github.com/quic-go/quic-go v0.59.0 github.com/tinylib/msgp v1.6.3 + github.com/yuin/goldmark v1.7.16 golang.org/x/crypto v0.48.0 golang.org/x/image v0.36.0 golang.org/x/net v0.50.0 diff --git a/go.sum b/go.sum index 3ab743b..c9c1f11 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= diff --git a/internal/core/printer.go b/internal/core/printer.go index 8d30fe0..afcfc9f 100644 --- a/internal/core/printer.go +++ b/internal/core/printer.go @@ -4,6 +4,7 @@ import ( "bytes" "io" "os" + "sync" ) // Sequence represents an ANSI escape sequence. @@ -61,7 +62,7 @@ func (h *Handle) Stdout() *Printer { // Printer allows for writing data with optional ANSI escape sequences based on // the color settings for a target. type Printer struct { - file *os.File + file io.Writer buf bytes.Buffer useColor bool } @@ -81,6 +82,25 @@ func newPrinter(file *os.File, isTerm bool, c Color) *Printer { return &Printer{file: file, useColor: useColor} } +// TestPrinter returns a Printer suitable for testing. All output, including +// flushed data, is captured and accessible via Bytes. +func TestPrinter(useColor bool) *Printer { + return &Printer{file: &lockedBuffer{}, useColor: useColor} +} + +// lockedBuffer is a goroutine-safe bytes.Buffer used as the flush target in +// test printers so that Bytes can return the combined buffered + flushed data. +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (lb *lockedBuffer) Write(p []byte) (int, error) { + lb.mu.Lock() + defer lb.mu.Unlock() + return lb.buf.Write(p) +} + // Set writes the provided Sequence. func (p *Printer) Set(s Sequence) { if p.useColor { @@ -108,8 +128,17 @@ func (p *Printer) Discard() { p.buf.Reset() } -// Bytes returns the current contents of the buffer. +// Bytes returns the current contents of the buffer. For test printers created +// with TestPrinter, this also includes previously flushed data. func (p *Printer) Bytes() []byte { + if lb, ok := p.file.(*lockedBuffer); ok { + lb.mu.Lock() + flushed := lb.buf.Bytes() + lb.mu.Unlock() + if len(flushed) > 0 { + return append(flushed, p.buf.Bytes()...) + } + } return p.buf.Bytes() } diff --git a/internal/format/contenttype.go b/internal/format/contenttype.go index 2fbb98b..f57d6ce 100644 --- a/internal/format/contenttype.go +++ b/internal/format/contenttype.go @@ -18,6 +18,7 @@ const ( TypeHTML TypeImage TypeJSON + TypeMarkdown TypeMsgPack TypeNDJSON TypeProtobuf @@ -82,6 +83,8 @@ func GetContentType(headers http.Header) (ContentType, string) { return TypeCSV, charset case "html": return TypeHTML, charset + case "markdown", "x-markdown": + return TypeMarkdown, charset case "event-stream": return TypeSSE, charset case "xml": diff --git a/internal/format/markdown.go b/internal/format/markdown.go new file mode 100644 index 0000000..1530715 --- /dev/null +++ b/internal/format/markdown.go @@ -0,0 +1,678 @@ +package format + +import ( + "bytes" + "fmt" + "strings" + + "github.com/ryanfowler/fetch/internal/core" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + east "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" +) + +// FormatMarkdown formats the provided Markdown to the Printer. +func FormatMarkdown(buf []byte, p *core.Printer) error { + if len(buf) == 0 { + return nil + } + + frontMatter, rest := extractFrontMatter(buf) + if frontMatter != nil { + if err := FormatYAML(frontMatter, p); err != nil { + // FormatYAML calls Discard on error, so fall through to + // render the original buffer as plain markdown. + rest = buf + } else if len(rest) > 0 { + p.WriteString("\n\n") + } + } + + if len(rest) == 0 { + return nil + } + + md := goldmark.New(goldmark.WithExtensions(extension.Strikethrough, extension.Table)) + doc := md.Parser().Parse(text.NewReader(rest)) + + r := &mdRenderer{printer: p, source: rest} + return ast.Walk(doc, r.walk) +} + +// extractFrontMatter checks if buf starts with YAML front matter delimited by +// "---" lines and returns the front matter (including delimiters) and the +// remaining content. If no valid front matter is found, it returns (nil, buf). +func extractFrontMatter(buf []byte) (frontMatter, rest []byte) { + // Must start with "---\n" or "---\r\n". + if !bytes.HasPrefix(buf, []byte("---\n")) && !bytes.HasPrefix(buf, []byte("---\r\n")) { + return nil, buf + } + + // Scan past the opening delimiter line. + i := bytes.IndexByte(buf, '\n') + 1 + + // Look for a closing "---" line. + for i < len(buf) { + lineEnd := bytes.IndexByte(buf[i:], '\n') + var line []byte + if lineEnd == -1 { + line = buf[i:] + } else { + line = buf[i : i+lineEnd] + } + + // Trim trailing \r for Windows line endings. + line = bytes.TrimRight(line, "\r") + + if string(line) == "---" { + // End of front matter: include the closing delimiter line. + end := i + len(line) + if lineEnd != -1 { + end = i + lineEnd + 1 + } + return buf[:end], buf[end:] + } + + if lineEnd == -1 { + // Reached end of input without closing delimiter. + break + } + i += lineEnd + 1 + } + + return nil, buf +} + +// mdRenderer walks a goldmark AST and writes ANSI-styled output. +type mdRenderer struct { + printer *core.Printer + source []byte + styles []core.Sequence + bqDepth int +} + +// pushStyle appends a style to the stack and sets it on the printer. +func (r *mdRenderer) pushStyle(s core.Sequence) { + r.styles = append(r.styles, s) + r.printer.Set(s) +} + +// popStyle removes the top style, resets all, and re-applies remaining styles. +func (r *mdRenderer) popStyle() { + if len(r.styles) == 0 { + return + } + r.styles = r.styles[:len(r.styles)-1] + r.printer.Reset() + for _, s := range r.styles { + r.printer.Set(s) + } +} + +// writeBqPrefix writes the blockquote prefix ("> " repeated bqDepth times). +func (r *mdRenderer) writeBqPrefix() { + for range r.bqDepth { + r.printer.Set(core.Dim) + r.printer.WriteString(">") + r.popAllAndRestore() + r.printer.WriteString(" ") + } +} + +// popAllAndRestore resets then re-applies all styles on the stack. +func (r *mdRenderer) popAllAndRestore() { + r.printer.Reset() + for _, s := range r.styles { + r.printer.Set(s) + } +} + +// reapplyStyles re-emits all styles on the stack without resetting first. +// Used after line breaks to ensure styles persist across lines. +func (r *mdRenderer) reapplyStyles() { + for _, s := range r.styles { + r.printer.Set(s) + } +} + +// nodeText returns the concatenated text content of a node's children segments. +func (r *mdRenderer) nodeText(n ast.Node) string { + var sb strings.Builder + for i := 0; i < n.Lines().Len(); i++ { + seg := n.Lines().At(i) + sb.Write(seg.Value(r.source)) + } + return sb.String() +} + +func (r *mdRenderer) walk(n ast.Node, entering bool) (ast.WalkStatus, error) { + switch v := n.(type) { + case *ast.Document: + // No output for document node. + + case *ast.Heading: + if entering { + r.writeBqPrefix() + hashes := strings.Repeat("#", v.Level) + r.printer.Set(core.Bold) + r.printer.Set(core.Blue) + r.printer.WriteString(hashes) + r.popAllAndRestore() + if v.HasChildren() { + r.printer.WriteString(" ") + } + r.pushStyle(core.Bold) + } else { + r.popStyle() + r.printer.WriteString("\n") + if n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + } + + case *ast.Paragraph: + if entering { + // Write blockquote prefix unless inside a list item (marker already written). + if _, inList := n.Parent().(*ast.ListItem); !inList { + r.writeBqPrefix() + } + } else { + r.printer.WriteString("\n") + // Add blank line after paragraph unless it's the last child or in a tight list. + if n.NextSibling() != nil { + if _, inList := n.Parent().(*ast.ListItem); !inList { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + } + } + + case *ast.TextBlock: + if entering { + if _, inList := n.Parent().(*ast.ListItem); !inList { + r.writeBqPrefix() + } + } else { + r.printer.WriteString("\n") + } + + case *ast.ThematicBreak: + if entering { + r.writeBqPrefix() + r.printer.Set(core.Dim) + r.printer.WriteString("---") + r.popAllAndRestore() + r.printer.WriteString("\n") + if n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + } + + case *ast.CodeBlock: + if entering { + // Indented code block — render as cyan, skip children. + content := r.nodeText(v) + content = strings.TrimRight(content, "\n") + for _, line := range strings.Split(content, "\n") { + r.writeBqPrefix() + r.printer.Set(core.Cyan) + r.printer.WriteString(line) + r.popAllAndRestore() + r.printer.WriteString("\n") + } + if n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + return ast.WalkSkipChildren, nil + } + + case *ast.FencedCodeBlock: + if entering { + status, err := r.renderFencedCodeBlock(v) + if err != nil { + return status, err + } + if n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + return status, nil + } + + case *ast.Blockquote: + if entering { + r.bqDepth++ + } else { + r.bqDepth-- + if n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + } + + case *ast.List: + if !entering && n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + + case *ast.ListItem: + if entering { + list := v.Parent().(*ast.List) + indent := r.listIndent(v) + r.writeBqPrefix() + r.printer.WriteString(indent) + r.printer.Set(core.Blue) + if list.IsOrdered() { + num := list.Start + // Count preceding siblings to determine item number. + for sib := v.Parent().FirstChild(); sib != nil && sib != n; sib = sib.NextSibling() { + num++ + } + r.printer.WriteString(fmt.Sprintf("%d.", num)) + } else { + r.printer.WriteString(string(list.Marker)) + } + r.popAllAndRestore() + r.printer.WriteString(" ") + } else { + // newline handled by child paragraph/textblock + } + + case *ast.HTMLBlock: + if entering { + content := r.nodeText(v) + content = strings.TrimRight(content, "\n") + for _, line := range strings.Split(content, "\n") { + r.writeBqPrefix() + r.printer.Set(core.Dim) + r.printer.WriteString(line) + r.popAllAndRestore() + r.printer.WriteString("\n") + } + if n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + return ast.WalkSkipChildren, nil + } + + // Inline elements. + + case *ast.Text: + if entering { + r.printer.Write(v.Segment.Value(r.source)) + if v.SoftLineBreak() { + r.printer.WriteString("\n") + r.writeBqPrefix() + r.reapplyStyles() + } + if v.HardLineBreak() { + r.printer.WriteString("\n") + r.writeBqPrefix() + r.reapplyStyles() + } + } + + case *ast.CodeSpan: + if entering { + r.printer.Set(core.Cyan) + for c := v.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + seg := t.Segment + // Restore leading whitespace that paragraph parsing + // stripped from continuation lines. + start := seg.Start + for start > 0 && (r.source[start-1] == ' ' || r.source[start-1] == '\t') { + start-- + } + if start < seg.Start && start > 0 && r.source[start-1] == '\n' { + r.printer.Write(r.source[start:seg.Start]) + } + r.printer.Write(seg.Value(r.source)) + } + } + r.popAllAndRestore() + return ast.WalkSkipChildren, nil + } + + case *ast.Emphasis: + if entering { + if v.Level == 2 { + r.pushStyle(core.Bold) + } else { + r.pushStyle(core.Italic) + } + } else { + r.popStyle() + } + + case *ast.Link: + if entering { + r.printer.Set(core.Dim) + r.printer.WriteString("[") + r.popAllAndRestore() + r.pushStyle(core.Underline) + } else { + r.popStyle() + r.printer.Set(core.Dim) + r.printer.WriteString("](") + r.popAllAndRestore() + r.printer.Set(core.Cyan) + r.printer.Write(v.Destination) + r.popAllAndRestore() + r.printer.Set(core.Dim) + r.printer.WriteString(")") + r.popAllAndRestore() + } + + case *ast.Image: + if entering { + r.printer.Set(core.Dim) + r.printer.WriteString("![") + r.popAllAndRestore() + r.pushStyle(core.Italic) + } else { + r.popStyle() + r.printer.Set(core.Dim) + r.printer.WriteString("](") + r.popAllAndRestore() + r.printer.Set(core.Cyan) + r.printer.Write(v.Destination) + r.popAllAndRestore() + r.printer.Set(core.Dim) + r.printer.WriteString(")") + r.popAllAndRestore() + } + + case *ast.AutoLink: + if entering { + r.printer.WriteString("<") + r.printer.Set(core.Cyan) + r.printer.Write(v.URL(r.source)) + r.popAllAndRestore() + r.printer.WriteString(">") + } + + case *ast.RawHTML: + if entering { + r.printer.Set(core.Dim) + for i := 0; i < v.Segments.Len(); i++ { + seg := v.Segments.At(i) + r.printer.Write(seg.Value(r.source)) + } + r.popAllAndRestore() + return ast.WalkSkipChildren, nil + } + + case *ast.String: + if entering { + r.printer.Write(v.Value) + } + + // Extension: Strikethrough + case *east.Strikethrough: + if entering { + r.pushStyle(core.Dim) + } else { + r.popStyle() + } + + // Extension: Table + case *east.Table: + if entering { + status, err := r.renderTable(v) + if err != nil { + return status, err + } + if n.NextSibling() != nil { + r.writeBqPrefix() + r.printer.WriteString("\n") + } + return status, nil + } + } + + return ast.WalkContinue, nil +} + +// listIndent returns indentation string based on list nesting depth. +func (r *mdRenderer) listIndent(item *ast.ListItem) string { + depth := 0 + for p := item.Parent(); p != nil; p = p.Parent() { + if _, ok := p.(*ast.ListItem); ok { + depth++ + } + } + return strings.Repeat(" ", depth) +} + +// renderFencedCodeBlock renders a fenced code block, delegating to known formatters. +func (r *mdRenderer) renderFencedCodeBlock(v *ast.FencedCodeBlock) (ast.WalkStatus, error) { + lang := "" + if v.Info != nil { + lang = strings.TrimSpace(string(v.Info.Segment.Value(r.source))) + // Strip anything after a space (e.g. "js title=foo" → "js"). + if idx := strings.IndexByte(lang, ' '); idx >= 0 { + lang = lang[:idx] + } + } + + // Opening fence. + r.writeBqPrefix() + r.printer.Set(core.Dim) + r.printer.WriteString("```") + if lang != "" { + r.printer.WriteString(lang) + } + r.printer.Reset() + r.printer.WriteString("\n") + + // Collect body lines. + var lines []string + for i := 0; i < v.Lines().Len(); i++ { + seg := v.Lines().At(i) + line := string(seg.Value(r.source)) + line = strings.TrimRight(line, "\n") + lines = append(lines, line) + } + + // Try to delegate to a known formatter. Flush before delegating so + // that a formatter's Discard on error cannot erase prior output. + // Skip delegation inside blockquotes since delegated formatters + // cannot emit blockquote prefixes. + delegated := false + if lang != "" && len(lines) > 0 && r.bqDepth == 0 { + content := strings.Join(lines, "\n") + if formatter := getFormatterForLang(lang); formatter != nil { + r.printer.Flush() + if err := formatter([]byte(content), r.printer); err == nil { + delegated = true + if content[len(content)-1] != '\n' { + r.printer.WriteString("\n") + } + } + } + } + + // Default: write each line in cyan independently. + if !delegated { + for _, line := range lines { + r.writeBqPrefix() + r.printer.Set(core.Cyan) + r.printer.WriteString(line) + r.printer.Reset() + r.printer.WriteString("\n") + } + } + + // Closing fence. + r.writeBqPrefix() + r.printer.Set(core.Dim) + r.printer.WriteString("```") + r.printer.Reset() + r.printer.WriteString("\n") + + return ast.WalkSkipChildren, nil +} + +// renderTable renders a table extension node. +func (r *mdRenderer) renderTable(table *east.Table) (ast.WalkStatus, error) { + // Collect all rows (header + body). + var rows [][]string + var alignments []east.Alignment + + for row := table.FirstChild(); row != nil; row = row.NextSibling() { + var cells []string + for cell := row.FirstChild(); cell != nil; cell = cell.NextSibling() { + cells = append(cells, r.inlineText(cell)) + } + rows = append(rows, cells) + } + + if len(rows) == 0 { + return ast.WalkSkipChildren, nil + } + + // Get alignments from header cells. + if header := table.FirstChild(); header != nil { + for cell := header.FirstChild(); cell != nil; cell = cell.NextSibling() { + if tc, ok := cell.(*east.TableCell); ok { + alignments = append(alignments, tc.Alignment) + } + } + } + + // Calculate column widths. + numCols := 0 + for _, row := range rows { + if len(row) > numCols { + numCols = len(row) + } + } + widths := make([]int, numCols) + for _, row := range rows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + // Minimum width of 3 for separator row. + for i := range widths { + if widths[i] < 3 { + widths[i] = 3 + } + } + + // Render header row. + r.writeBqPrefix() + r.renderTableRow(rows[0], widths, true) + r.printer.WriteString("\n") + + // Render separator. + r.writeBqPrefix() + r.printer.Set(core.Dim) + r.printer.WriteString("|") + for i, w := range widths { + a := east.AlignNone + if i < len(alignments) { + a = alignments[i] + } + switch a { + case east.AlignLeft: + r.printer.WriteString(":") + r.printer.WriteString(strings.Repeat("-", w-1)) + case east.AlignRight: + r.printer.WriteString(strings.Repeat("-", w-1)) + r.printer.WriteString(":") + case east.AlignCenter: + r.printer.WriteString(":") + r.printer.WriteString(strings.Repeat("-", w-2)) + r.printer.WriteString(":") + default: + r.printer.WriteString(strings.Repeat("-", w)) + } + r.printer.WriteString("|") + } + r.popAllAndRestore() + r.printer.WriteString("\n") + + // Render body rows. + for _, row := range rows[1:] { + r.writeBqPrefix() + r.renderTableRow(row, widths, false) + r.printer.WriteString("\n") + } + + return ast.WalkSkipChildren, nil +} + +// renderTableRow renders a single table row. +func (r *mdRenderer) renderTableRow(cells []string, widths []int, isHeader bool) { + r.printer.Set(core.Dim) + r.printer.WriteString("|") + r.popAllAndRestore() + for i, w := range widths { + cell := "" + if i < len(cells) { + cell = cells[i] + } + r.printer.WriteString(" ") + if isHeader { + r.printer.Set(core.Bold) + } + r.printer.WriteString(cell) + if isHeader { + r.popAllAndRestore() + } + r.printer.WriteString(strings.Repeat(" ", w-len(cell))) + r.printer.WriteString(" ") + r.printer.Set(core.Dim) + r.printer.WriteString("|") + r.popAllAndRestore() + } +} + +// inlineText extracts the plain text from an inline node's children. +func (r *mdRenderer) inlineText(n ast.Node) string { + var sb strings.Builder + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + r.collectText(&sb, c) + } + return sb.String() +} + +func (r *mdRenderer) collectText(sb *strings.Builder, n ast.Node) { + if t, ok := n.(*ast.Text); ok { + sb.Write(t.Segment.Value(r.source)) + } + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + r.collectText(sb, c) + } +} + +// getFormatterForLang returns a BufferedFormatter for the given language tag, +// or nil if no matching formatter exists. +func getFormatterForLang(lang string) BufferedFormatter { + switch strings.ToLower(lang) { + case "json": + return FormatJSON + case "yaml", "yml": + return FormatYAML + case "xml": + return FormatXML + case "html": + return FormatHTML + case "css": + return FormatCSS + default: + return nil + } +} diff --git a/internal/format/markdown_test.go b/internal/format/markdown_test.go new file mode 100644 index 0000000..7328572 --- /dev/null +++ b/internal/format/markdown_test.go @@ -0,0 +1,996 @@ +package format + +import ( + "strings" + "testing" + + "github.com/ryanfowler/fetch/internal/core" +) + +func TestFormatMarkdown(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + // Empty / whitespace inputs + {name: "empty input", input: "", wantErr: false}, + {name: "only whitespace", input: " \n\n \n", wantErr: false}, + {name: "only blank lines", input: "\n\n\n", wantErr: false}, + + // ATX headings + {name: "h1", input: "# Heading 1\n", wantErr: false}, + {name: "h2", input: "## Heading 2\n", wantErr: false}, + {name: "h3", input: "### Heading 3\n", wantErr: false}, + {name: "h4", input: "#### Heading 4\n", wantErr: false}, + {name: "h5", input: "##### Heading 5\n", wantErr: false}, + {name: "h6", input: "###### Heading 6\n", wantErr: false}, + {name: "heading no text", input: "#\n", wantErr: false}, + {name: "not a heading no space", input: "#not a heading\n", wantErr: false}, + {name: "consecutive headings", input: "# First\n## Second\n### Third\n", wantErr: false}, + + // Setext headings + {name: "setext h1", input: "Heading\n=======\n", wantErr: false}, + {name: "setext h2", input: "Heading\n-------\n", wantErr: false}, + + // Fenced code blocks + {name: "backtick fence", input: "```\ncode\n```\n", wantErr: false}, + {name: "tilde fence", input: "~~~\ncode\n~~~\n", wantErr: false}, + {name: "fence with language", input: "```go\nfmt.Println()\n```\n", wantErr: false}, + {name: "unclosed fence", input: "```\ncode\n", wantErr: false}, + {name: "nested backticks in tilde", input: "~~~\n```\ncode\n```\n~~~\n", wantErr: false}, + {name: "empty fence", input: "```\n```\n", wantErr: false}, + + // Fenced code block delegation + {name: "json code block", input: "```json\n{\"key\": \"value\"}\n```\n", wantErr: false}, + {name: "yaml code block", input: "```yaml\nkey: value\n```\n", wantErr: false}, + {name: "xml code block", input: "```xml\n\n```\n", wantErr: false}, + {name: "html code block", input: "```html\n
hello
\n```\n", wantErr: false}, + {name: "css code block", input: "```css\nbody { color: red; }\n```\n", wantErr: false}, + + // Blockquotes + {name: "blockquote single line", input: "> hello\n", wantErr: false}, + {name: "blockquote multiple lines", input: "> line1\n> line2\n", wantErr: false}, + {name: "nested blockquote", input: "> > nested\n", wantErr: false}, + {name: "blockquote with inline", input: "> **bold** in quote\n", wantErr: false}, + + // Unordered lists + {name: "dash list", input: "- item1\n- item2\n", wantErr: false}, + {name: "star list", input: "* item1\n* item2\n", wantErr: false}, + {name: "plus list", input: "+ item1\n+ item2\n", wantErr: false}, + {name: "nested list 2-space", input: "- parent\n - child\n", wantErr: false}, + {name: "nested list 4-space", input: "- parent\n - child\n", wantErr: false}, + {name: "list with inline", input: "- **bold** item\n", wantErr: false}, + + // Ordered lists + {name: "ordered list", input: "1. first\n2. second\n", wantErr: false}, + {name: "multi-digit ordered", input: "10. item ten\n", wantErr: false}, + {name: "nested ordered", input: "1. parent\n 1. child\n", wantErr: false}, + + // Horizontal rules + {name: "dash rule", input: "---\n", wantErr: false}, + {name: "star rule", input: "***\n", wantErr: false}, + {name: "underscore rule", input: "___\n", wantErr: false}, + {name: "long dash rule", input: "----\n", wantErr: false}, + {name: "spaced dash rule", input: "- - -\n", wantErr: false}, + {name: "spaced star rule", input: "* * *\n", wantErr: false}, + {name: "spaced underscore rule", input: "_ _ _\n", wantErr: false}, + + // Inline formatting + {name: "bold stars", input: "**bold**\n", wantErr: false}, + {name: "bold underscores", input: "__bold__\n", wantErr: false}, + {name: "italic star", input: "*italic*\n", wantErr: false}, + {name: "italic underscore", input: "_italic_\n", wantErr: false}, + {name: "bold italic", input: "***bold italic***\n", wantErr: false}, + {name: "code span", input: "`code`\n", wantErr: false}, + {name: "double backtick code", input: "`` code ``\n", wantErr: false}, + {name: "link", input: "[text](url)\n", wantErr: false}, + {name: "image", input: "![alt](url)\n", wantErr: false}, + {name: "unclosed bold", input: "**text\n", wantErr: false}, + {name: "unclosed italic", input: "*text\n", wantErr: false}, + {name: "unclosed bracket", input: "[text\n", wantErr: false}, + {name: "empty bold", input: "****\n", wantErr: false}, + {name: "markers in code span", input: "`**not bold**`\n", wantErr: false}, + {name: "link with bold text", input: "[**bold**](url)\n", wantErr: false}, + + // Strikethrough + {name: "strikethrough", input: "~~deleted~~\n", wantErr: false}, + + // Tables + {name: "simple table", input: "| A | B |\n|---|---|\n| 1 | 2 |\n", wantErr: false}, + + // Autolinks + {name: "autolink", input: "\n", wantErr: false}, + + // Multi-line constructs + {name: "multi-line paragraph", input: "Hello\nworld\n", wantErr: false}, + {name: "multi-line link", input: "[link\ntext](url)\n", wantErr: false}, + + // Mixed content + {name: "mixed document", input: "# Title\n\nSome **bold** text.\n\n- item1\n- item2\n\n```\ncode\n```\n\n> quote\n", wantErr: false}, + + // Front matter + {name: "front matter simple", input: "---\ntitle: Hello\n---\n# Body\n", wantErr: false}, + {name: "front matter empty", input: "---\n---\n", wantErr: false}, + {name: "front matter only", input: "---\nkey: value\n---\n", wantErr: false}, + {name: "front matter crlf", input: "---\r\ntitle: Hi\r\n---\r\n# Body\r\n", wantErr: false}, + {name: "front matter unclosed", input: "---\ntitle: Hello\n", wantErr: false}, + {name: "front matter complex yaml", input: "---\ntags:\n - go\n - cli\ndate: 2024-01-01\n---\n# Post\n", wantErr: false}, + + // Windows line endings + {name: "crlf", input: "# Hello\r\n\r\nworld\r\n", wantErr: false}, + + // Very long line + {name: "long line", input: strings.Repeat("a", 10000) + "\n", wantErr: false}, + + // List immediately after heading + {name: "heading then list", input: "# Title\n- item\n", wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if (err != nil) != tt.wantErr { + t.Errorf("FormatMarkdown() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFormatMarkdownHeadingOutput(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "h1", + input: "# Hello", + want: "# Hello\n", + }, + { + name: "h2", + input: "## World", + want: "## World\n", + }, + { + name: "h6", + input: "###### Deep", + want: "###### Deep\n", + }, + { + name: "heading no text", + input: "#", + want: "#\n", + }, + { + name: "not a heading", + input: "#nospace", + want: "#nospace\n", + }, + { + name: "setext h1", + input: "Title\n=====", + want: "# Title\n", + }, + { + name: "setext h2", + input: "Title\n-----", + want: "## Title\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatMarkdownBlockquoteOutput(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "simple", + input: "> hello", + want: "> hello\n", + }, + { + name: "nested", + input: "> > nested", + want: "> > nested\n", + }, + { + name: "multi-line blockquote", + input: "> line1\n> line2", + want: "> line1\n> line2\n", + }, + { + name: "heading in blockquote", + input: "> # Heading", + want: "> # Heading\n", + }, + { + name: "heading then paragraph in blockquote", + input: "> # Title\n>\n> text", + want: "> # Title\n> \n> text\n", + }, + { + name: "thematic break in blockquote", + input: "> ---", + want: "> ---\n", + }, + { + name: "fenced code in blockquote", + input: "> ```\n> code\n> ```", + want: "> ```\n> code\n> ```\n", + }, + { + name: "list in blockquote", + input: "> - a\n> - b", + want: "> - a\n> - b\n", + }, + { + name: "nested heading in blockquote", + input: "> > # Deep", + want: "> > # Deep\n", + }, + { + name: "blockquote with multiple block types", + input: "> # Title\n>\n> text\n>\n> ---", + want: "> # Title\n> \n> text\n> \n> ---\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatMarkdownListOutput(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "dash list", + input: "- item1\n- item2", + want: "- item1\n- item2\n", + }, + { + name: "star list", + input: "* item1\n* item2", + want: "* item1\n* item2\n", + }, + { + name: "plus list", + input: "+ item", + want: "+ item\n", + }, + { + name: "nested list", + input: "- parent\n - child", + want: "- parent\n - child\n", + }, + { + name: "ordered list", + input: "1. first\n2. second", + want: "1. first\n2. second\n", + }, + { + name: "multi-digit ordered", + input: "10. item", + want: "10. item\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatMarkdownHorizontalRuleOutput(t *testing.T) { + // All thematic break forms normalize to "---". + tests := []struct { + name string + input string + want string + }{ + {name: "dashes", input: "---", want: "---\n"}, + {name: "stars", input: "***", want: "---\n"}, + {name: "underscores", input: "___", want: "---\n"}, + {name: "long dashes", input: "-----", want: "---\n"}, + {name: "spaced dashes", input: "- - -", want: "---\n"}, + {name: "spaced stars", input: "* * *", want: "---\n"}, + {name: "spaced underscores", input: "_ _ _", want: "---\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatMarkdownDashDashNotRule(t *testing.T) { + // "--" is too short to be a horizontal rule. + p := core.TestPrinter(false) + err := FormatMarkdown([]byte("--"), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != "--\n" { + t.Errorf("got %q, want %q", got, "--\n") + } +} + +func TestFormatMarkdownCodeBlockOutput(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "simple code block", + input: "```\nhello\n```", + want: "```\nhello\n```\n", + }, + { + name: "tilde fence normalizes to backticks", + input: "~~~\nhello\n~~~", + want: "```\nhello\n```\n", + }, + { + name: "unclosed fence still closes", + input: "```\nhello", + want: "```\nhello\n```\n", + }, + { + name: "empty code block", + input: "```\n```", + want: "```\n```\n", + }, + { + name: "code with language", + input: "```go\nfmt.Println()\n```", + want: "```go\nfmt.Println()\n```\n", + }, + { + name: "fenced code preserves indentation", + input: "```js\nconst r = await fetch(\n `https://example.com`,\n {\n headers: {\n Accept: \"text/markdown\",\n },\n },\n);\n```", + want: "```js\nconst r = await fetch(\n `https://example.com`,\n {\n headers: {\n Accept: \"text/markdown\",\n },\n },\n);\n```\n", + }, + { + name: "indented code block", + input: "text\n\n line1\n line2\n line3", + want: "text\n\nline1\n line2\nline3\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatMarkdownInlineOutput(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "bold", + input: "**bold**", + want: "bold\n", + }, + { + name: "italic", + input: "*italic*", + want: "italic\n", + }, + { + name: "code span", + input: "`code`", + want: "code\n", + }, + { + name: "link", + input: "[text](url)", + want: "[text](url)\n", + }, + { + name: "image", + input: "![alt](url)", + want: "![alt](url)\n", + }, + { + name: "markers in code span", + input: "`**not bold**`", + want: "**not bold**\n", + }, + { + name: "bold italic", + input: "***text***", + want: "text\n", + }, + { + name: "unclosed bold passthrough", + input: "**text", + want: "**text\n", + }, + { + name: "inline mixed", + input: "Hello **world** and *foo*", + want: "Hello world and foo\n", + }, + { + name: "double backtick code span", + input: "`` code ``", + want: "code\n", + }, + { + name: "multi-line code span preserves indentation", + input: "``\nconst r = await fetch(\n `https://example.com`,\n {\n headers: {\n Accept: \"text/markdown\",\n },\n },\n);\n``", + want: "const r = await fetch(\n `https://example.com`,\n {\n headers: {\n Accept: \"text/markdown\",\n },\n },\n);\n", + }, + { + name: "utf8 plain text", + input: "café résumé naïve", + want: "café résumé naïve\n", + }, + { + name: "utf8 with bold", + input: "**café**", + want: "café\n", + }, + { + name: "utf8 cjk characters", + input: "Hello 世界", + want: "Hello 世界\n", + }, + { + name: "utf8 emoji", + input: "Hello 👋🌍", + want: "Hello 👋🌍\n", + }, + { + name: "strikethrough", + input: "~~deleted~~", + want: "deleted\n", + }, + { + name: "autolink", + input: "", + want: "\n", + }, + { + name: "nested emphasis", + input: "**bold and *italic***", + want: "bold and italic\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatMarkdownColor(t *testing.T) { + tests := []struct { + name string + input string + seqs []string + }{ + { + name: "heading uses bold blue", + input: "# Title", + seqs: []string{"\x1b[1m", "\x1b[34m"}, + }, + { + name: "bold text uses bold", + input: "**bold**", + seqs: []string{"\x1b[1m"}, + }, + { + name: "italic text uses italic", + input: "*italic*", + seqs: []string{"\x1b[3m"}, + }, + { + name: "code span uses cyan", + input: "`code`", + seqs: []string{"\x1b[36m"}, + }, + { + name: "link url uses cyan", + input: "[text](http://example.com)", + seqs: []string{"\x1b[36m"}, + }, + { + name: "link text uses underline and brackets use dim", + input: "[text](url)", + seqs: []string{"\x1b[4m", "\x1b[2m"}, + }, + { + name: "horizontal rule uses dim", + input: "---", + seqs: []string{"\x1b[2m"}, + }, + { + name: "blockquote marker uses dim", + input: "> text", + seqs: []string{"\x1b[2m"}, + }, + { + name: "list marker uses blue", + input: "- item", + seqs: []string{"\x1b[34m"}, + }, + { + name: "ordered list marker uses blue", + input: "1. item", + seqs: []string{"\x1b[34m"}, + }, + { + name: "strikethrough uses dim", + input: "~~deleted~~", + seqs: []string{"\x1b[2m"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(true) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + for _, seq := range tt.seqs { + if !strings.Contains(output, seq) { + t.Errorf("output should contain ANSI sequence %q, got: %q", seq, output) + } + } + }) + } +} + +func TestFormatMarkdownCodeBlockDelegation(t *testing.T) { + // JSON code block should produce formatted JSON output (indented). + input := "```json\n{\"a\":1}\n```" + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + // The JSON formatter should indent the output. + if !strings.Contains(output, " ") { + t.Errorf("expected JSON delegation to produce indented output, got: %q", output) + } + if !strings.Contains(output, "\"a\"") { + t.Errorf("expected output to contain key \"a\", got: %q", output) + } +} + +func TestFormatMarkdownWindowsLineEndings(t *testing.T) { + input := "# Hello\r\n\r\nworld\r\n" + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + if strings.Contains(output, "\r") { + t.Errorf("output should not contain \\r, got: %q", output) + } + if !strings.Contains(output, "# Hello") { + t.Errorf("output should contain '# Hello', got: %q", output) + } +} + +func TestFormatMarkdownMixedDocument(t *testing.T) { + input := `# Title + +Some **bold** and *italic* text. + +- item 1 +- item 2 + +1. ordered +2. list + +> a blockquote + +` + "```" + ` +code block +` + "```" + ` + +--- +` + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + for _, want := range []string{"# Title", "bold", "italic", "- item 1", "1. ordered", ">", "code block", "---"} { + if !strings.Contains(output, want) { + t.Errorf("output should contain %q, got: %q", want, output) + } + } +} + +func TestFormatMarkdownEmptyInput(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(""), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + if len(p.Bytes()) != 0 { + t.Errorf("expected empty output for empty input, got: %q", string(p.Bytes())) + } +} + +func TestFormatMarkdownImageInline(t *testing.T) { + input := "See ![logo](http://example.com/logo.png) here" + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + if !strings.Contains(output, "logo") { + t.Errorf("expected output to contain 'logo', got: %q", output) + } + if !strings.Contains(output, "http://example.com/logo.png") { + t.Errorf("expected output to contain URL, got: %q", output) + } +} + +func TestFormatMarkdownTable(t *testing.T) { + input := "| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |\n" + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + if !strings.Contains(output, "Alice") { + t.Errorf("expected output to contain 'Alice', got: %q", output) + } + if !strings.Contains(output, "Bob") { + t.Errorf("expected output to contain 'Bob', got: %q", output) + } + if !strings.Contains(output, "|") { + t.Errorf("expected output to contain '|', got: %q", output) + } +} + +func TestFormatMarkdownMultiLineLink(t *testing.T) { + // A link split across lines should still be parsed. + input := "[click\nhere](http://example.com)" + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + if !strings.Contains(output, "http://example.com") { + t.Errorf("expected output to contain URL, got: %q", output) + } +} + +func TestFormatMarkdownNestedBlockquote(t *testing.T) { + input := "> > deeply nested" + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + if !strings.Contains(output, ">") { + t.Errorf("expected output to contain blockquote markers, got: %q", output) + } + if !strings.Contains(output, "deeply nested") { + t.Errorf("expected output to contain text, got: %q", output) + } +} + +func TestFormatMarkdownBlockSpacing(t *testing.T) { + // Every block-level element should be separated by a blank line from the + // following block when both are present. + tests := []struct { + name string + input string + want string + }{ + { + name: "list then paragraph", + input: "- a\n- b\n\nparagraph\n", + want: "- a\n- b\n\nparagraph\n", + }, + { + name: "thematic break then paragraph", + input: "---\n\nparagraph\n", + want: "---\n\nparagraph\n", + }, + { + name: "html block then paragraph", + input: "
hi
\n\nparagraph\n", + want: "
hi
\n\nparagraph\n", + }, + { + name: "table then paragraph", + input: "| A |\n|---|\n| 1 |\n\nparagraph\n", + want: "| A |\n|---|\n| 1 |\n\nparagraph\n", + }, + { + name: "blockquote then paragraph", + input: "> quote\n\nparagraph\n", + want: "> quote\n\nparagraph\n", + }, + { + name: "heading then paragraph", + input: "# Title\n\nparagraph\n", + want: "# Title\n\nparagraph\n", + }, + { + name: "fenced code then paragraph", + input: "```\ncode\n```\n\nparagraph\n", + want: "```\ncode\n```\n\nparagraph\n", + }, + { + name: "invalid json fence preserves prior output", + input: "# Title\n\n```json\nnot json\n```\n\ntail\n", + want: "# Title\n\n```json\nnot json\n```\n\ntail\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractFrontMatter(t *testing.T) { + tests := []struct { + name string + input string + wantFM string // empty means nil + wantRest string + }{ + { + name: "simple", + input: "---\ntitle: Hello\n---\nbody\n", + wantFM: "---\ntitle: Hello\n---\n", + wantRest: "body\n", + }, + { + name: "no front matter", + input: "# Heading\n", + wantFM: "", + wantRest: "# Heading\n", + }, + { + name: "standalone dash rule", + input: "---\n", + wantFM: "", + wantRest: "---\n", + }, + { + name: "unclosed", + input: "---\ntitle: Hello\nbody\n", + wantFM: "", + wantRest: "---\ntitle: Hello\nbody\n", + }, + { + name: "empty front matter", + input: "---\n---\n", + wantFM: "---\n---\n", + wantRest: "", + }, + { + name: "front matter only", + input: "---\nkey: val\n---\n", + wantFM: "---\nkey: val\n---\n", + wantRest: "", + }, + { + name: "crlf", + input: "---\r\ntitle: Hi\r\n---\r\nbody\r\n", + wantFM: "---\r\ntitle: Hi\r\n---\r\n", + wantRest: "body\r\n", + }, + { + name: "leading space not front matter", + input: " ---\ntitle: x\n---\n", + wantFM: "", + wantRest: " ---\ntitle: x\n---\n", + }, + { + name: "complex yaml", + input: "---\ntags:\n - go\n - cli\ndate: 2024-01-01\n---\n# Post\n", + wantFM: "---\ntags:\n - go\n - cli\ndate: 2024-01-01\n---\n", + wantRest: "# Post\n", + }, + { + name: "closing without newline", + input: "---\nkey: val\n---", + wantFM: "---\nkey: val\n---", + wantRest: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fm, rest := extractFrontMatter([]byte(tt.input)) + gotFM := string(fm) + gotRest := string(rest) + if tt.wantFM == "" { + if fm != nil { + t.Errorf("expected nil front matter, got %q", gotFM) + } + } else if gotFM != tt.wantFM { + t.Errorf("front matter: got %q, want %q", gotFM, tt.wantFM) + } + if gotRest != tt.wantRest { + t.Errorf("rest: got %q, want %q", gotRest, tt.wantRest) + } + }) + } +} + +func TestFormatMarkdownFrontMatter(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "front matter with body", + input: "---\ntitle: Hello\n---\n# Heading\n", + want: "---\ntitle: Hello\n---\n\n# Heading\n", + }, + { + name: "front matter only", + input: "---\nkey: value\n---\n", + want: "---\nkey: value\n---", + }, + { + name: "empty front matter", + input: "---\n---\nbody\n", + want: "---\n---\n\nbody\n", + }, + { + name: "no front matter passthrough", + input: "# Heading\n", + want: "# Heading\n", + }, + { + name: "unclosed treated as markdown", + input: "---\ntitle: Hello\n", + want: "---\n\ntitle: Hello\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(false) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + got := string(p.Bytes()) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatMarkdownFrontMatterColor(t *testing.T) { + tests := []struct { + name string + input string + seqs []string + }{ + { + name: "dim delimiters and blue keys", + input: "---\ntitle: Hello\n---\n", + seqs: []string{"\x1b[2m", "\x1b[34m"}, + }, + { + name: "front matter with heading body", + input: "---\nkey: val\n---\n# Title\n", + seqs: []string{"\x1b[2m", "\x1b[34m", "\x1b[1m"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := core.TestPrinter(true) + err := FormatMarkdown([]byte(tt.input), p) + if err != nil { + t.Fatalf("FormatMarkdown() error = %v", err) + } + output := string(p.Bytes()) + for _, seq := range tt.seqs { + if !strings.Contains(output, seq) { + t.Errorf("output should contain ANSI sequence %q, got: %q", seq, output) + } + } + }) + } +} diff --git a/internal/format/registry.go b/internal/format/registry.go index 139d61c..0667dd0 100644 --- a/internal/format/registry.go +++ b/internal/format/registry.go @@ -23,6 +23,7 @@ func init() { TypeCSV: FormatCSV, TypeHTML: FormatHTML, TypeJSON: FormatJSON, + TypeMarkdown: FormatMarkdown, TypeMsgPack: FormatMsgPack, TypeProtobuf: FormatProtobuf, TypeXML: FormatXML,