337 lines
8.2 KiB
Go
337 lines
8.2 KiB
Go
// 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("&")
|
||
|
|
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)
|
||
|
|
}
|