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 }