Download
389 lines 10 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
// Package htmlmd turns a web page into Markdown for the model to read: its
// headings, paragraphs, lists, tables and code, without scripts, styles,
// navigation, images or where links lead. The page is untrusted; parsing
// it is x/net/html's, which follows the HTML5 spec, and this package only
// walks the tree that makes.
package htmlmd

import (
	"fmt"
	"io"
	"strings"
	"unicode"

	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// Page is a converted page.
type Page struct {
	Markdown string
	Headings []Heading
	// Anchors are the lines (from 1) where the elements with each id start,
	// for finding a URL's #fragment.
	Anchors map[string]int
}

// Heading is one of a page's headings.
type Heading struct {
	Line  int // from 1
	Level int // 1 to 6
	Text  string
}

// Convert reads an HTML page and turns it into Markdown.
func Convert(r io.Reader) (Page, error) {
	doc, err := html.Parse(r)
	if err != nil {
		return Page{}, err
	}
	w := &writer{page: Page{Anchors: map[string]int{}}}
	w.walk(doc)
	w.flush()
	w.page.Markdown = w.out.String()
	return w.page, nil
}

type writer struct {
	page  Page
	out   strings.Builder
	lines int
	// text gathers the words of the block being read, until a block
	// boundary writes them out as a line.
	text strings.Builder
	// prefix is what begins each line in the open lists and quotes. A list
	// item's first line has its marker in place of its indent.
	prefix []string
	marker string
	// lists holds, for each open list, the number of its next item, or 0
	// for a bulleted list.
	lists []int
	// blank asks for a blank line before the next block, outside lists.
	blank bool
	// pending are ids whose elements start on the next line written.
	pending []string
}

// skipped are elements with nothing to read in them.
var skipped = map[atom.Atom]bool{
	atom.Head: true, atom.Script: true, atom.Style: true, atom.Noscript: true,
	atom.Template: true, atom.Nav: true, atom.Footer: true, atom.Svg: true,
	atom.Math: true, atom.Iframe: true, atom.Object: true, atom.Embed: true,
	atom.Img: true, atom.Picture: true, atom.Video: true, atom.Audio: true,
	atom.Canvas: true, atom.Form: true, atom.Button: true, atom.Input: true,
	atom.Select: true, atom.Textarea: true, atom.Dialog: true,
}

// skippedRoles are the ARIA roles of what nav, footer and a search form
// hold, for pages that mark them with a role instead.
var skippedRoles = map[string]bool{"navigation": true, "search": true, "contentinfo": true}

// blocks are elements that start and end a block of text.
var blocks = map[atom.Atom]bool{
	atom.P: true, atom.Div: true, atom.Section: true, atom.Article: true,
	atom.Main: true, atom.Aside: true, atom.Header: true, atom.Figure: true,
	atom.Figcaption: true, atom.Details: true, atom.Summary: true,
	atom.Address: true, atom.Center: true, atom.Body: true, atom.Html: true,
	atom.Caption: true, atom.Fieldset: true, atom.Legend: true,
	atom.Dt: true, atom.Hgroup: true,
}

// skip reports whether an element is left out, with all it holds: skipped,
// hidden, or in a skipped role.
func skip(n *html.Node) bool {
	return skipped[n.DataAtom] || hasAttr(n, "hidden") || attr(n, "aria-hidden") == "true" || skippedRoles[attr(n, "role")]
}

var headings = map[atom.Atom]int{
	atom.H1: 1, atom.H2: 2, atom.H3: 3, atom.H4: 4, atom.H5: 5, atom.H6: 6,
}

func (w *writer) walk(n *html.Node) {
	switch n.Type {
	case html.DocumentNode:
		w.children(n)
		return
	case html.TextNode:
		w.text.WriteString(n.Data)
		return
	case html.ElementNode:
	default: // comments, doctypes
		return
	}
	if skip(n) {
		return
	}
	inline := !blocks[n.DataAtom] && headings[n.DataAtom] == 0
	switch n.DataAtom {
	case atom.Pre, atom.Ul, atom.Ol, atom.Li, atom.Dl, atom.Dd, atom.Blockquote, atom.Table, atom.Hr, atom.Br:
		inline = false
	}
	if !inline {
		w.flush()
	}
	// An inline element's id is on the line its block will be written to.
	if id := attr(n, "id"); id != "" {
		w.pending = append(w.pending, id)
	}
	if level := headings[n.DataAtom]; level > 0 {
		w.heading(n, level)
		return
	}
	switch n.DataAtom {
	case atom.Br:
		// A line break ends a line, but not a block.
		w.blank = false
	case atom.Hr:
		w.separate()
		w.line("---")
		w.blank = true
	case atom.Pre:
		w.pre(n)
	case atom.Table:
		w.table(n)
	case atom.Code, atom.Kbd, atom.Samp, atom.Tt:
		w.text.WriteString("`")
		w.children(n)
		w.text.WriteString("`")
	case atom.Ul, atom.Ol:
		w.separate()
		next := 0
		if n.DataAtom == atom.Ol {
			next = 1
		}
		w.lists = append(w.lists, next)
		w.children(n)
		w.flush()
		w.lists = w.lists[:len(w.lists)-1]
		w.blank = len(w.lists) == 0
	case atom.Li:
		marker := "- "
		if k := len(w.lists) - 1; k >= 0 && w.lists[k] > 0 {
			marker = fmt.Sprintf("%d. ", w.lists[k])
			w.lists[k]++
		}
		w.indented(n, marker)
	case atom.Dd:
		w.indented(n, "")
	case atom.Blockquote:
		w.separate()
		w.prefix = append(w.prefix, "> ")
		w.children(n)
		w.flush()
		w.prefix = w.prefix[:len(w.prefix)-1]
		w.blank = true
	default:
		w.children(n)
		if !inline {
			w.flush()
		}
	}
}

func (w *writer) children(n *html.Node) {
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		w.walk(c)
	}
}

// indented writes a list item, or a definition, indented under the line
// before it; marker begins its first line.
func (w *writer) indented(n *html.Node, marker string) {
	indent := strings.Repeat(" ", max(len(marker), 2))
	w.prefix = append(w.prefix, indent)
	w.marker = marker
	w.children(n)
	w.flush()
	w.prefix = w.prefix[:len(w.prefix)-1]
	w.marker = ""
}

// heading writes a heading, with the ids inside it, since they name it.
func (w *writer) heading(n *html.Node, level int) {
	w.ids(n)
	text := words(textOf(n, false))
	if text == "" {
		return
	}
	w.separate()
	w.line(strings.Repeat("#", level) + " " + text)
	w.page.Headings = append(w.page.Headings, Heading{Line: w.lines, Level: level, Text: text})
	w.blank = true
}

// pre writes preformatted text as a fenced code block, as it is.
func (w *writer) pre(n *html.Node) {
	w.ids(n)
	text := strings.Trim(textOf(n, true), "\n")
	if strings.TrimSpace(text) == "" {
		return
	}
	// The fence must be longer than any run of backticks in the code.
	fence := "```"
	for strings.Contains(text, fence) {
		fence += "`"
	}
	w.separate()
	w.line(fence)
	for l := range strings.SplitSeq(text, "\n") {
		w.line(Clean(strings.TrimRight(l, " \t\r")))
	}
	w.line(fence)
	w.blank = true
}

// table writes a table's rows, the first one as its header. A table in a
// cell is read as the cell's text.
func (w *writer) table(n *html.Node) {
	w.ids(n)
	var rows [][]string
	var collect func(*html.Node)
	collect = func(n *html.Node) {
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			if c.Type != html.ElementNode || skip(c) {
				continue
			}
			if c.DataAtom != atom.Tr {
				collect(c) // thead, tbody, tfoot
				continue
			}
			var row []string
			for cell := c.FirstChild; cell != nil; cell = cell.NextSibling {
				if cell.Type == html.ElementNode && (cell.DataAtom == atom.Td || cell.DataAtom == atom.Th) {
					row = append(row, strings.ReplaceAll(words(textOf(cell, false)), "|", `\|`))
				}
			}
			if len(row) > 0 {
				rows = append(rows, row)
			}
		}
	}
	collect(n)
	if len(rows) == 0 {
		return
	}
	w.separate()
	for i, row := range rows {
		w.line("| " + strings.Join(row, " | ") + " |")
		if i == 0 {
			w.line(strings.Repeat("| --- ", len(row)) + "|")
		}
	}
	w.blank = true
}

// ids marks the ids in a subtree that is written as a whole as starting
// on its first line.
func (w *writer) ids(n *html.Node) {
	for c := n.FirstChild; c != nil; c = c.NextSibling {
		if c.Type == html.ElementNode {
			if id := attr(c, "id"); id != "" {
				w.pending = append(w.pending, id)
			}
			w.ids(c)
		}
	}
}

// flush writes the gathered words as a line.
func (w *writer) flush() {
	text := words(w.text.String())
	w.text.Reset()
	if text == "" {
		return
	}
	w.separate()
	w.line(text)
	w.blank = true
}

// separate writes the blank line a block asked for, except inside lists,
// whose items stay together.
func (w *writer) separate() {
	if w.blank && w.lines > 0 && len(w.lists) == 0 {
		w.out.WriteString(strings.TrimRight(strings.Join(w.prefix, ""), " "))
		w.out.WriteByte('\n')
		w.lines++
	}
	w.blank = false
}

// line writes a line after the prefix of the lists and quotes it is in.
func (w *writer) line(s string) {
	p := strings.Join(w.prefix, "")
	if w.marker != "" && strings.HasSuffix(p, strings.Repeat(" ", len(w.marker))) {
		p = p[:len(p)-len(w.marker)] + w.marker
	}
	w.marker = ""
	w.out.WriteString(strings.TrimRight(p+s, " "))
	w.out.WriteByte('\n')
	w.lines++
	for _, id := range w.pending {
		if _, ok := w.page.Anchors[id]; !ok {
			w.page.Anchors[id] = w.lines
		}
	}
	w.pending = w.pending[:0]
}

// textOf is the text in a subtree, leaving out what skip does. pre keeps
// line breaks (<br> is one); otherwise they are spaces, for words to fold.
func textOf(n *html.Node, pre bool) string {
	var b strings.Builder
	var walk func(*html.Node)
	walk = func(n *html.Node) {
		switch {
		case n.Type == html.TextNode:
			b.WriteString(n.Data)
		case n.Type != html.ElementNode || skip(n):
		case n.DataAtom == atom.Br:
			if pre {
				b.WriteString("\n")
			} else {
				b.WriteString(" ")
			}
		default:
			for c := n.FirstChild; c != nil; c = c.NextSibling {
				walk(c)
			}
		}
	}
	walk(n)
	return b.String()
}

// words folds runs of space, line breaks included, into single spaces.
// It drops the pilcrows sites link headings and definitions to themselves
// with, and control characters, which no page needs and a terminal might
// act on.
func words(s string) string {
	return strings.Join(strings.Fields(strings.ReplaceAll(Clean(s), "¶", " ")), " ")
}

// Clean drops control characters but line breaks and tabs.
func Clean(s string) string {
	return strings.Map(func(r rune) rune {
		if unicode.IsControl(r) && r != '\n' && r != '\t' {
			return -1
		}
		return r
	}, s)
}

func attr(n *html.Node, key string) string {
	for _, a := range n.Attr {
		if a.Namespace == "" && a.Key == key {
			return a.Val
		}
	}
	return ""
}

func hasAttr(n *html.Node, key string) bool {
	for _, a := range n.Attr {
		if a.Namespace == "" && a.Key == key {
			return true
		}
	}
	return false
}