366 lines
8.4 KiB
Go
366 lines
8.4 KiB
Go
package markdown
|
|
|
|
import (
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
const (
|
|
markBold = "bold"
|
|
markItalic = "italic"
|
|
markLink = "link"
|
|
markCode = "code"
|
|
|
|
codeLanguagePrefix = "language-"
|
|
)
|
|
|
|
type mark struct {
|
|
kind string
|
|
href string
|
|
}
|
|
|
|
type inline struct {
|
|
text string
|
|
marks []mark
|
|
}
|
|
|
|
type blockKind int
|
|
|
|
const (
|
|
kindHeading blockKind = iota
|
|
kindParagraph
|
|
kindList
|
|
kindBlockquote
|
|
kindCode
|
|
kindRule
|
|
kindImage
|
|
)
|
|
|
|
type block struct {
|
|
kind blockKind
|
|
level int
|
|
inlines []inline
|
|
ordered bool
|
|
items [][]block
|
|
children []block
|
|
language string
|
|
text string
|
|
src string
|
|
alt string
|
|
align string
|
|
}
|
|
|
|
var transparentBlocks = map[string]bool{
|
|
"div": true, "section": true, "article": true, "header": true, "footer": true,
|
|
"main": true, "aside": true, "nav": true, "figure": true, "figcaption": true,
|
|
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true,
|
|
"td": true, "th": true, "dl": true, "dt": true, "dd": true, "details": true,
|
|
"summary": true, "li": true,
|
|
}
|
|
|
|
var skippedElements = map[string]bool{
|
|
"script": true, "style": true, "template": true, "head": true,
|
|
"noscript": true, "iframe": true, "object": true, "embed": true,
|
|
}
|
|
|
|
var alignments = map[string]bool{"center": true, "right": true, "justify": true}
|
|
|
|
var allowedLinkSchemes = map[string]bool{
|
|
"": true, "http": true, "https": true, "mailto": true, "tel": true,
|
|
}
|
|
|
|
func parseBlocks(source string) ([]block, error) {
|
|
context := &html.Node{Type: html.ElementNode, DataAtom: atom.Body, Data: "body"}
|
|
nodes, err := html.ParseFragment(strings.NewReader(source), context)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
collector := newCollector(false, paragraphBlock)
|
|
collector.walkAll(nodes, nil)
|
|
return collector.finish(false), nil
|
|
}
|
|
|
|
func paragraphBlock(inlines []inline) block {
|
|
return block{kind: kindParagraph, inlines: inlines}
|
|
}
|
|
|
|
func headingBlock(level int) func([]inline) block {
|
|
return func(inlines []inline) block {
|
|
return block{kind: kindHeading, level: level, inlines: inlines}
|
|
}
|
|
}
|
|
|
|
type collector struct {
|
|
out []block
|
|
current []inline
|
|
keepBlank bool
|
|
build func([]inline) block
|
|
}
|
|
|
|
func newCollector(keepBlank bool, build func([]inline) block) *collector {
|
|
return &collector{keepBlank: keepBlank, build: build}
|
|
}
|
|
|
|
func (c *collector) walkAll(nodes []*html.Node, marks []mark) {
|
|
for _, node := range nodes {
|
|
c.walk(node, marks)
|
|
}
|
|
}
|
|
|
|
func (c *collector) walkChildren(parent *html.Node, marks []mark) {
|
|
for child := parent.FirstChild; child != nil; child = child.NextSibling {
|
|
c.walk(child, marks)
|
|
}
|
|
}
|
|
|
|
func (c *collector) addText(text string, marks []mark) {
|
|
if text == "" {
|
|
return
|
|
}
|
|
c.current = appendInline(c.current, inline{text: text, marks: marks})
|
|
}
|
|
|
|
func (c *collector) flush() {
|
|
if len(c.current) == 0 {
|
|
return
|
|
}
|
|
if c.keepBlank || hasVisibleText(c.current) {
|
|
c.out = append(c.out, c.build(c.current))
|
|
}
|
|
c.current = nil
|
|
}
|
|
|
|
func (c *collector) finish(emitEmpty bool) []block {
|
|
hadBlocks := len(c.out) > 0
|
|
c.flush()
|
|
if emitEmpty && !hadBlocks && len(c.out) == 0 {
|
|
c.out = append(c.out, c.build(nil))
|
|
}
|
|
return c.out
|
|
}
|
|
|
|
func (c *collector) walk(node *html.Node, marks []mark) {
|
|
switch node.Type {
|
|
case html.TextNode:
|
|
c.addText(node.Data, marks)
|
|
return
|
|
case html.ElementNode:
|
|
default:
|
|
return
|
|
}
|
|
|
|
tag := node.Data
|
|
if skippedElements[tag] {
|
|
return
|
|
}
|
|
|
|
switch tag {
|
|
case "br":
|
|
c.addText("\n", marks)
|
|
case "strong", "b":
|
|
c.walkChildren(node, withMark(marks, mark{kind: markBold}))
|
|
case "em", "i":
|
|
c.walkChildren(node, withMark(marks, mark{kind: markItalic}))
|
|
case "code":
|
|
c.walkChildren(node, withMark(marks, mark{kind: markCode}))
|
|
case "a":
|
|
href := safeHref(attr(node, "href"))
|
|
if href == "" {
|
|
c.walkChildren(node, marks)
|
|
return
|
|
}
|
|
c.walkChildren(node, withMark(marks, mark{kind: markLink, href: href}))
|
|
case "img":
|
|
c.flush()
|
|
if src := attr(node, "src"); src != "" {
|
|
c.out = append(c.out, block{kind: kindImage, src: src, alt: attr(node, "alt")})
|
|
}
|
|
case "hr":
|
|
c.flush()
|
|
c.out = append(c.out, block{kind: kindRule})
|
|
case "p":
|
|
c.flush()
|
|
c.out = append(c.out, parseTextBlock(node, paragraphBlock)...)
|
|
case "h1", "h2", "h3", "h4", "h5", "h6":
|
|
c.flush()
|
|
level, _ := strconv.Atoi(tag[1:])
|
|
c.out = append(c.out, parseTextBlock(node, headingBlock(level))...)
|
|
case "ul", "ol":
|
|
c.flush()
|
|
c.out = append(c.out, parseList(node))
|
|
case "blockquote":
|
|
c.flush()
|
|
c.out = append(c.out, block{kind: kindBlockquote, children: parseContainer(node)})
|
|
case "pre":
|
|
c.flush()
|
|
c.out = append(c.out, parseCodeBlock(node))
|
|
default:
|
|
if transparentBlocks[tag] {
|
|
c.flush()
|
|
c.walkChildren(node, marks)
|
|
c.flush()
|
|
return
|
|
}
|
|
c.walkChildren(node, marks)
|
|
}
|
|
}
|
|
|
|
func parseTextBlock(node *html.Node, build func([]inline) block) []block {
|
|
inner := newCollector(true, build)
|
|
inner.walkChildren(node, nil)
|
|
blocks := inner.finish(true)
|
|
if align := alignFromStyle(attr(node, "style")); align != "" {
|
|
for index := range blocks {
|
|
if blocks[index].kind == kindParagraph || blocks[index].kind == kindHeading {
|
|
blocks[index].align = align
|
|
}
|
|
}
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
func alignFromStyle(style string) string {
|
|
for _, declaration := range strings.Split(style, ";") {
|
|
name, value, found := strings.Cut(declaration, ":")
|
|
if !found || strings.ToLower(strings.TrimSpace(name)) != "text-align" {
|
|
continue
|
|
}
|
|
if align := strings.ToLower(strings.TrimSpace(value)); alignments[align] {
|
|
return align
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseContainer(node *html.Node) []block {
|
|
inner := newCollector(false, paragraphBlock)
|
|
inner.walkChildren(node, nil)
|
|
return inner.finish(false)
|
|
}
|
|
|
|
func parseList(node *html.Node) block {
|
|
list := block{kind: kindList, ordered: node.Data == "ol"}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if child.Type == html.ElementNode && child.Data == "li" {
|
|
list.items = append(list.items, parseContainer(child))
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
|
|
func parseCodeBlock(node *html.Node) block {
|
|
code := block{kind: kindCode, text: strings.TrimSuffix(textContent(node), "\n")}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if child.Type == html.ElementNode && child.Data == "code" {
|
|
code.language = languageFromClass(attr(child, "class"))
|
|
break
|
|
}
|
|
}
|
|
return code
|
|
}
|
|
|
|
func languageFromClass(class string) string {
|
|
for _, name := range strings.Fields(class) {
|
|
if strings.HasPrefix(name, codeLanguagePrefix) {
|
|
return strings.TrimPrefix(name, codeLanguagePrefix)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func textContent(node *html.Node) string {
|
|
var builder strings.Builder
|
|
var visit func(*html.Node)
|
|
visit = func(n *html.Node) {
|
|
switch {
|
|
case n.Type == html.TextNode:
|
|
builder.WriteString(n.Data)
|
|
case n.Type == html.ElementNode && skippedElements[n.Data]:
|
|
return
|
|
case n.Type == html.ElementNode && n.Data == "br":
|
|
builder.WriteString("\n")
|
|
}
|
|
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
|
visit(child)
|
|
}
|
|
}
|
|
visit(node)
|
|
return builder.String()
|
|
}
|
|
|
|
func attr(node *html.Node, name string) string {
|
|
for _, a := range node.Attr {
|
|
if a.Key == name {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func safeHref(href string) string {
|
|
trimmed := strings.TrimSpace(href)
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil || !allowedLinkSchemes[strings.ToLower(parsed.Scheme)] {
|
|
return ""
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func withMark(marks []mark, added mark) []mark {
|
|
for _, existing := range marks {
|
|
if existing.kind == added.kind {
|
|
return marks
|
|
}
|
|
}
|
|
next := make([]mark, 0, len(marks)+1)
|
|
next = append(next, marks...)
|
|
return append(next, added)
|
|
}
|
|
|
|
func hasVisibleText(inlines []inline) bool {
|
|
for _, item := range inlines {
|
|
if strings.TrimSpace(item.text) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func sameMarks(a, b []mark) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for index := range a {
|
|
if a[index] != b[index] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func appendInline(inlines []inline, next inline) []inline {
|
|
if last := len(inlines) - 1; last >= 0 && sameMarks(inlines[last].marks, next.marks) {
|
|
inlines[last].text += next.text
|
|
return inlines
|
|
}
|
|
return append(inlines, next)
|
|
}
|
|
|
|
func withoutCodeMarks(inlines []inline) []inline {
|
|
merged := make([]inline, 0, len(inlines))
|
|
for _, item := range inlines {
|
|
kept := make([]mark, 0, len(item.marks))
|
|
for _, m := range item.marks {
|
|
if m.kind != markCode {
|
|
kept = append(kept, m)
|
|
}
|
|
}
|
|
merged = appendInline(merged, inline{text: item.text, marks: kept})
|
|
}
|
|
return merged
|
|
}
|