// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package markdown import ( "strconv" "strings" ) // renderer serialises the block tree. Every block ends its output with a // newline; a paragraph inside a tight list item is the one exception, where // the text stands without its own wrapper. type renderer struct { out []byte refs map[string]reference footnotes *footnoteTracker } func (r *renderer) puts(s string) { r.out = append(r.out, s...) } func (r *renderer) blocks(nodes []*Node) { for _, n := range nodes { r.block(n) } } func (r *renderer) block(n *Node) { switch n.kind { case kindParagraph: task := taskInput(n) if tightParent(n) { r.puts(task) r.inlineContent(n.content) if !lastChild(n) { r.puts("\n") } return } r.puts("

") r.puts(task) r.inlineContent(n.content) r.puts("

\n") case kindHeading: level := strconv.Itoa(n.level) r.puts("") r.inlineContent(n.content) r.puts("\n") case kindCodeBlock: r.puts("
")
		r.puts(escapeHTML(string(n.content)))
		r.puts("
\n") case kindHTMLBlock: r.out = append(r.out, n.content...) case kindBlockquote: r.puts("
\n") r.blocks(n.children) r.puts("
\n") case kindList: switch n.listKind { case bulletList: r.puts("\n") case orderedList: if n.start != 1 { r.puts(`
    ` + "\n") } else { r.puts("
      \n") } r.blocks(n.children) r.puts("
    \n") } case kindListItem: r.itemLike("li", n) case kindDefItem: r.itemLike("dd", n) case kindDefTerm: r.puts("
    ") r.inlineContent(n.content) r.puts("
    \n") case kindDefList: r.puts("
    \n") r.blocks(n.children) r.puts("
    \n") case kindThematicBreak: r.puts("
    \n") case kindTable: r.puts("\n\n\n") for i, cell := range n.header { r.puts("") r.inlineContent(cell) r.puts("\n") } r.puts("\n\n") if len(n.rows) > 0 { r.puts("\n") for _, row := range n.rows { r.puts("\n") for i, cell := range row { r.puts("") r.inlineContent(cell) r.puts("\n") } r.puts("\n") } r.puts("\n") } r.puts("
    \n") } } // itemLike renders a container of a list-shaped structure: an item of a // list or a definition of a definition list. An empty container never // gains a newline, whatever the tightness. func (r *renderer) itemLike(tag string, n *Node) { firstIsParagraph := len(n.children) > 0 && n.children[0].kind == kindParagraph r.puts("<" + tag + ">") if len(n.children) > 0 && (!n.parent.tight || !firstIsParagraph) { r.puts("\n") } r.blocks(n.children) r.puts("\n") } // taskInput renders the checkbox of a task list item, at the head of the // item's first paragraph. func taskInput(n *Node) string { if n.parent == nil || n.parent.kind != kindListItem || !n.parent.task { return "" } siblings := n.parent.children if siblings[0] != n { return "" } if n.parent.taskDone { return ` ` } return ` ` } // alignAttr writes the alignment attribute of a table column. The // attribute value keeps the spelling the HTML vocabulary defines. func (r *renderer) alignAttr(a uint8) { switch a { case alignLeft: r.puts(` align="left"`) case alignCentre: r.puts(` align="center"`) case alignRight: r.puts(` align="right"`) } } // inlineContent parses and renders the inline content of a leaf block. func (r *renderer) inlineContent(content []byte) { for _, n := range parseInlines(content, r.refs, r.footnotes) { r.renderInline(n) } } func (r *renderer) renderInline(n *inline) { switch n.kind { case inlText: r.puts(escapeHTML(n.literal)) case inlCode: r.puts("") r.puts(escapeHTML(n.literal)) r.puts("") case inlRawHTML: r.puts(n.literal) case inlEmph: r.puts("") r.inlineNodes(n.children) r.puts("") case inlStrong: r.puts("") r.inlineNodes(n.children) r.puts("") case inlStrikethrough: r.puts("") r.inlineNodes(n.children) r.puts("") case inlLink: r.puts(`") r.inlineNodes(n.children) r.puts("") case inlImage: r.puts(`` + escapeHTML(plainText(n.children)) + `") case inlBreak: r.puts("
    \n") case inlFootnoteRef: id := "fnref-" + strconv.Itoa(n.num) if n.occurrence > 1 { id += "-" + strconv.Itoa(n.occurrence) } num := strconv.Itoa(n.num) r.puts(`` + num + ``) } } // footnoteSection renders the definitions of every referenced footnote, in // the order of their first reference. The back reference lands at the end // of the definition's last paragraph. func (r *renderer) footnoteSection() { if len(r.footnotes.order) == 0 { return } r.puts("
    \n
      \n") for i, def := range r.footnotes.order { num := strconv.Itoa(i + 1) r.puts(`
    1. ` + "\n") backref := ` ` + "↩" + `` for j, c := range def.children { if c.kind == kindParagraph && j+1 == len(def.children) { r.puts("

      ") r.inlineContent(c.content) r.puts(backref) r.puts("

      \n") continue } r.block(c) } r.puts("
    2. \n") } r.puts("
    \n
    \n") } func (r *renderer) inlineNodes(nodes []*inline) { for _, n := range nodes { r.renderInline(n) } } // plainText renders inline nodes without markup, for the alt text of an // image. func plainText(nodes []*inline) string { var b strings.Builder for _, n := range nodes { switch n.kind { case inlText, inlCode, inlRawHTML: b.WriteString(n.literal) case inlBreak: b.WriteByte('\n') default: b.WriteString(plainText(n.children)) } } return b.String() } // urlSafe marks the bytes that stay literal in an escaped destination. const urlSafe = "!#$%()*+,-./:;=?@_~$" // escapeURL escapes a link destination for an href or src attribute: the // ampersand and the apostrophe become entities, the bytes outside the safe // set become percent escapes. func escapeURL(s string) string { var b strings.Builder for i := 0; i < len(s); i++ { c := s[i] switch { case c == '&': b.WriteString("&") case c == '\'': b.WriteString("'") case isAlnum(c) || strings.IndexByte(urlSafe, c) >= 0: b.WriteByte(c) default: b.WriteByte('%') b.WriteByte("0123456789ABCDEF"[c>>4]) b.WriteByte("0123456789ABCDEF"[c&0x0f]) } } return b.String() } // tightParent reports whether the node is a paragraph directly inside an // item of a tight list or a definition of a tight definition list. func tightParent(n *Node) bool { if n.parent == nil || n.parent.parent == nil { return false } switch { case n.parent.kind == kindListItem && n.parent.parent.kind == kindList: return n.parent.parent.tight case n.parent.kind == kindDefItem && n.parent.parent.kind == kindDefList: return n.parent.parent.tight } return false } // lastChild reports whether the node is the last child of its parent. func lastChild(n *Node) bool { siblings := n.parent.children return siblings[len(siblings)-1] == n } // infoWord returns the first word of a code block's info string. func infoWord(info string) string { fields := strings.Fields(info) if len(fields) == 0 { return "" } return fields[0] } var htmlEscaper = strings.NewReplacer( "&", "&", "<", "<", ">", ">", `"`, """, ) // escapeHTML escapes plain text for HTML output. func escapeHTML(s string) string { return htmlEscaper.Replace(s) }