specstream/pkg/extract/markdown.go
agent-engineering ecf100b917 Initial commit: SpecStream structured extraction gateway
Go MCP server (port 3991) for agent content safety.
Deterministic parsers, typed output, no raw text fields.

- HTML (goquery), Markdown (goldmark), Text parsers
- Injection pattern detection and redaction
- SHA256 content fingerprinting
2026-05-25 21:18:57 -07:00

311 lines
8.1 KiB
Go

// Package extract_md implements the Markdown content extractor using goldmark.
//
// Security model: same as HTMLExtractor — no raw text passthrough.
// Parses markdown AST into typed Section, CodeBlock, List, Table structures.
// Never returns the raw markdown string.
package extract
import (
"bytes"
"crypto/sha256"
"fmt"
"io"
"strings"
"time"
"github.com/sscoble/specstream/pkg/types"
"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"
)
type MarkdownExtractor struct{}
func NewMarkdownExtractor() *MarkdownExtractor {
return &MarkdownExtractor{}
}
func (e *MarkdownExtractor) Detect(r io.ReaderAt, size int64) (bool, error) {
if size > 1024 {
size = 1024
}
buf := make([]byte, size)
n, err := r.ReadAt(buf, 0)
if err != nil && err != io.EOF {
return false, err
}
sample := string(buf[:n])
hasHeading := len(sample) > 0 && (sample[0] == '#' || strings.Contains(sample, "\n#"))
hasList := strings.Contains(sample, "- ") || strings.Contains(sample, "* ")
hasLink := strings.Contains(sample, "](")
return (hasHeading || hasList) && hasLink, nil
}
func (e *MarkdownExtractor) MIMETypes() []string {
return []string{"text/markdown", "text/x-markdown"}
}
func (e *MarkdownExtractor) Extract(r io.Reader, req types.ExtractRequest) (*types.ExtractedContent, error) {
start := time.Now()
raw, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read markdown: %w", err)
}
hash := sha256.Sum256(raw)
fingerprint := fmt.Sprintf("%x", hash)
content := &types.ExtractedContent{
Source: types.Source{
MIMEType: "text/markdown",
ByteSize: int64(len(raw)),
IsTrusted: false,
},
ContentType: "markdown",
ExtractedAt: time.Now(),
Metadata: types.Metadata{
ExtractorVersion: "specstream/0.1.0",
SHA256: fingerprint,
StrippedPatterns: make(map[string]int),
TagCounts: make(map[string]int),
},
}
// Extract title from first h1
lines := strings.Split(string(raw), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# ") {
content.Title = strings.TrimPrefix(trimmed, "# ")
break
}
if strings.HasPrefix(trimmed, "#") {
continue
}
if trimmed != "" && !strings.HasPrefix(trimmed, "---") {
if content.Description == "" && len(trimmed) > 10 {
content.Description = trimmed
if len(content.Description) > 200 {
content.Description = content.Description[:200]
}
}
break
}
}
// Parse with goldmark using GFM extensions (tables, etc.)
md := goldmark.New(
goldmark.WithExtensions(
extension.Table,
extension.Strikethrough,
extension.TaskList,
),
)
doc := md.Parser().Parse(text.NewReader(raw))
var currentSection *types.Section
flushSection := func() {
if currentSection != nil && len(currentSection.Paragraphs) > 0 {
content.Sections = append(content.Sections, *currentSection)
}
}
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n.Kind() {
case ast.KindHeading:
flushSection()
h := n.(*ast.Heading)
text := mdInlineText(h, raw)
currentSection = &types.Section{
Heading: text,
Level: h.Level,
Paragraphs: []string{},
}
case ast.KindParagraph:
// Extract links from paragraphs
if req.ExtractLinks {
extractLinksFromNode(n, raw, content)
}
text := mdInlineText(n, raw)
text = sanitizeParagraph(text)
if text == "" {
return ast.WalkSkipChildren, nil
}
if currentSection == nil {
currentSection = &types.Section{
Paragraphs: []string{},
}
}
currentSection.Paragraphs = append(currentSection.Paragraphs, text)
case ast.KindFencedCodeBlock:
cb := n.(*ast.FencedCodeBlock)
lang := string(cb.Language(raw))
var codeBuf bytes.Buffer
for child := cb.FirstChild(); child != nil; child = child.NextSibling() {
if t, ok := child.(*ast.Text); ok {
codeBuf.Write(t.Segment.Value(raw))
}
}
content.CodeBlocks = append(content.CodeBlocks, types.CodeBlock{
Language: lang,
Code: strings.TrimSpace(codeBuf.String()),
})
case ast.KindList:
l := n.(*ast.List)
ordered := l.IsOrdered()
items := []string{}
for child := l.FirstChild(); child != nil; child = child.NextSibling() {
if li, ok := child.(*ast.ListItem); ok {
items = append(items, mdListItemText(li, raw))
if req.ExtractLinks {
extractLinksFromNode(li, raw, content)
}
}
}
if len(items) > 0 {
content.Lists = append(content.Lists, types.List{
Ordered: ordered,
Items: items,
})
}
case east.KindTable:
if !req.ExtractTables {
return ast.WalkSkipChildren, nil
}
t := n.(*east.Table)
table := types.Table{}
for row := t.FirstChild(); row != nil; row = row.NextSibling() {
switch row.Kind() {
case east.KindTableHeader:
hdr := row.(*east.TableHeader)
for cell := hdr.FirstChild(); cell != nil; cell = cell.NextSibling() {
if tc, ok := cell.(*east.TableCell); ok {
table.Headers = append(table.Headers, mdInlineText(tc, raw))
if req.ExtractLinks {
extractLinksFromNode(tc, raw, content)
}
}
}
case east.KindTableRow:
tr := row.(*east.TableRow)
rowData := []string{}
for cell := tr.FirstChild(); cell != nil; cell = cell.NextSibling() {
if tc, ok := cell.(*east.TableCell); ok {
rowData = append(rowData, mdInlineText(tc, raw))
if req.ExtractLinks {
extractLinksFromNode(tc, raw, content)
}
}
}
if len(rowData) > 0 {
table.Rows = append(table.Rows, rowData)
}
}
}
if len(table.Rows) > 0 || len(table.Headers) > 0 {
content.Tables = append(content.Tables, table)
}
}
return ast.WalkContinue, nil
})
flushSection()
content.SectionCnt = len(content.Sections)
content.CharCount = countChars(content)
content.Metadata.ExtractionTimeMs = time.Since(start).Milliseconds()
return content, nil
}
// mdInlineText extracts plain text from inline content nodes.
func mdInlineText(n ast.Node, src []byte) string {
var buf bytes.Buffer
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
switch c := child.(type) {
case *ast.Text:
buf.Write(c.Segment.Value(src))
case *ast.CodeSpan:
buf.WriteByte('`')
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
if t, ok := cc.(*ast.Text); ok {
buf.Write(t.Segment.Value(src))
}
}
buf.WriteByte('`')
case *ast.Link:
dest := string(c.Destination)
text := mdInlineText(c, src)
if text != "" {
buf.WriteString(text)
buf.WriteString(" (")
buf.WriteString(dest)
buf.WriteString(")")
} else {
buf.WriteString(dest)
}
case *ast.Image:
dest := string(c.Destination)
alt := string(c.Title)
if alt == "" {
alt = dest
}
buf.WriteString("[image: ")
buf.WriteString(alt)
buf.WriteString("]")
default:
buf.WriteString(mdInlineText(c, src))
}
}
return strings.TrimSpace(buf.String())
}
// extractLinksFromNode walks a node tree and extracts all Link destinations.
func extractLinksFromNode(n ast.Node, src []byte, content *types.ExtractedContent) {
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if link, ok := child.(*ast.Link); ok {
dest := string(link.Destination)
if dest == "" || strings.HasPrefix(dest, "#") {
continue
}
text := mdInlineText(link, src)
// Deduplicate by URL
dup := false
for _, existing := range content.Links {
if existing.Href == dest {
dup = true
break
}
}
if !dup {
content.Links = append(content.Links, types.Link{
Text: text,
Href: dest,
})
}
}
extractLinksFromNode(child, src, content)
}
}
// mdListItemText extracts plain text from a ListItem.
func mdListItemText(n ast.Node, src []byte) string {
var parts []string
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
parts = append(parts, mdInlineText(child, src))
}
return strings.TrimSpace(strings.Join(parts, " "))
}