feat: render Markdown, mathematics and Mermaid diagrams server-side
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user