Files

337 lines
8.2 KiB
Go
Raw Permalink Normal View History

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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("<p>")
r.puts(task)
r.inlineContent(n.content)
r.puts("</p>\n")
case kindHeading:
level := strconv.Itoa(n.level)
r.puts("<h" + level + ">")
r.inlineContent(n.content)
r.puts("</h" + level + ">\n")
case kindCodeBlock:
r.puts("<pre><code")
if word := infoWord(n.info); word != "" {
r.puts(` class="language-` + escapeHTML(word) + `"`)
}
r.puts(">")
r.puts(escapeHTML(string(n.content)))
r.puts("</code></pre>\n")
case kindHTMLBlock:
r.out = append(r.out, n.content...)
case kindBlockquote:
r.puts("<blockquote>\n")
r.blocks(n.children)
r.puts("</blockquote>\n")
case kindList:
switch n.listKind {
case bulletList:
r.puts("<ul>\n")
r.blocks(n.children)
r.puts("</ul>\n")
case orderedList:
if n.start != 1 {
r.puts(`<ol start="` + strconv.Itoa(n.start) + `">` + "\n")
} else {
r.puts("<ol>\n")
}
r.blocks(n.children)
r.puts("</ol>\n")
}
case kindListItem:
r.itemLike("li", n)
case kindDefItem:
r.itemLike("dd", n)
case kindDefTerm:
r.puts("<dt>")
r.inlineContent(n.content)
r.puts("</dt>\n")
case kindDefList:
r.puts("<dl>\n")
r.blocks(n.children)
r.puts("</dl>\n")
case kindThematicBreak:
r.puts("<hr />\n")
case kindTable:
r.puts("<table>\n<thead>\n<tr>\n")
for i, cell := range n.header {
r.puts("<th")
r.alignAttr(n.align[i])
r.puts(">")
r.inlineContent(cell)
r.puts("</th>\n")
}
r.puts("</tr>\n</thead>\n")
if len(n.rows) > 0 {
r.puts("<tbody>\n")
for _, row := range n.rows {
r.puts("<tr>\n")
for i, cell := range row {
r.puts("<td")
r.alignAttr(n.align[i])
r.puts(">")
r.inlineContent(cell)
r.puts("</td>\n")
}
r.puts("</tr>\n")
}
r.puts("</tbody>\n")
}
r.puts("</table>\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("</" + tag + ">\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 `<input checked="" disabled="" type="checkbox"> `
}
return `<input disabled="" type="checkbox"> `
}
// 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("<code>")
r.puts(escapeHTML(n.literal))
r.puts("</code>")
case inlRawHTML:
r.puts(n.literal)
case inlEmph:
r.puts("<em>")
r.inlineNodes(n.children)
r.puts("</em>")
case inlStrong:
r.puts("<strong>")
r.inlineNodes(n.children)
r.puts("</strong>")
case inlStrikethrough:
r.puts("<del>")
r.inlineNodes(n.children)
r.puts("</del>")
case inlLink:
r.puts(`<a href="` + escapeURL(n.dest) + `"`)
if n.hasTitle {
r.puts(` title="` + escapeHTML(n.title) + `"`)
}
r.puts(">")
r.inlineNodes(n.children)
r.puts("</a>")
case inlImage:
r.puts(`<img src="` + escapeURL(n.dest) + `" alt="` + escapeHTML(plainText(n.children)) + `"`)
if n.hasTitle {
r.puts(` title="` + escapeHTML(n.title) + `"`)
}
r.puts(" />")
case inlBreak:
r.puts("<br />\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(`<sup class="footnote-ref"><a href="#fn-` + num + `" id="` + id + `" data-footnote-ref>` + num + `</a></sup>`)
}
}
// 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("<section class=\"footnotes\" data-footnotes>\n<ol>\n")
for i, def := range r.footnotes.order {
num := strconv.Itoa(i + 1)
r.puts(`<li id="fn-` + num + `">` + "\n")
backref := ` <a href="#fnref-` + num + `" class="data-footnote-backref" aria-label="Back to reference ` + num + `">` + "↩" + `</a>`
for j, c := range def.children {
if c.kind == kindParagraph && j+1 == len(def.children) {
r.puts("<p>")
r.inlineContent(c.content)
r.puts(backref)
r.puts("</p>\n")
continue
}
r.block(c)
}
r.puts("</li>\n")
}
r.puts("</ol>\n</section>\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("&amp;")
case c == '\'':
b.WriteString("&#x27;")
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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
)
// escapeHTML escapes plain text for HTML output.
func escapeHTML(s string) string {
return htmlEscaper.Replace(s)
}