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.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