memos/internal/markdown/renderer/markdown_renderer.go

337 lines
8.3 KiB
Go

package renderer
import (
"bytes"
"fmt"
"strings"
gast "github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
mast "github.com/usememos/memos/internal/markdown/ast"
)
// MarkdownRenderer renders goldmark AST back to markdown text.
type MarkdownRenderer struct {
buf *bytes.Buffer
}
// NewMarkdownRenderer creates a new markdown renderer.
func NewMarkdownRenderer() *MarkdownRenderer {
return &MarkdownRenderer{
buf: &bytes.Buffer{},
}
}
// Render renders the AST node to markdown and returns the result.
func (r *MarkdownRenderer) Render(node gast.Node, source []byte) string {
r.buf.Reset()
r.renderNode(node, source, 0)
return r.buf.String()
}
// renderNode renders a single node and its children.
func (r *MarkdownRenderer) renderNode(node gast.Node, source []byte, depth int) {
switch n := node.(type) {
case *gast.Document:
r.renderChildren(n, source, depth)
case *gast.Paragraph:
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
separator := "\n\n"
if _, nextIsList := node.NextSibling().(*gast.List); nextIsList {
if item, ok := node.Parent().(*gast.ListItem); ok {
if list, ok := item.Parent().(*gast.List); ok && list.IsTight {
separator = "\n"
}
}
}
r.buf.WriteString(separator)
}
case *gast.Text:
// Text nodes store their content as segments in the source
segment := n.Segment
r.buf.Write(segment.Value(source))
if n.SoftLineBreak() {
r.buf.WriteByte('\n')
} else if n.HardLineBreak() {
r.buf.WriteString(" \n")
}
case *gast.CodeSpan:
r.buf.WriteByte('`')
r.renderChildren(n, source, depth)
r.buf.WriteByte('`')
case *gast.Emphasis:
symbol := "*"
if n.Level == 2 {
symbol = "**"
}
r.buf.WriteString(symbol)
r.renderChildren(n, source, depth)
r.buf.WriteString(symbol)
case *gast.Link:
r.buf.WriteString("[")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.AutoLink:
if start, end, ok := autoLinkLabelRange(n, source); ok {
if start > 0 && end < len(source) && source[start-1] == '<' && source[end] == '>' {
r.buf.Write(source[start-1 : end+1])
} else {
r.buf.Write(source[start:end])
}
break
}
url := n.URL(source)
if n.AutoLinkType == gast.AutoLinkEmail {
r.buf.WriteByte('<')
r.buf.Write(url)
r.buf.WriteByte('>')
} else {
r.buf.Write(url)
}
case *gast.Image:
r.buf.WriteString("![")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.Heading:
r.buf.WriteString(strings.Repeat("#", n.Level))
r.buf.WriteByte(' ')
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.CodeBlock, *gast.FencedCodeBlock:
r.renderCodeBlock(n, source)
case *gast.Blockquote:
// Render each child line with "> " prefix
r.renderBlockquote(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.List:
if node.PreviousSibling() != nil && !bytes.HasSuffix(r.buf.Bytes(), []byte{'\n'}) {
r.buf.WriteByte('\n')
}
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.ListItem:
r.renderListItem(n, source, depth)
case *gast.ThematicBreak:
r.buf.WriteString("---")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *east.Strikethrough:
r.buf.WriteString("~~")
r.renderChildren(n, source, depth)
r.buf.WriteString("~~")
case *east.TaskCheckBox:
if n.IsChecked {
r.buf.WriteString("[x] ")
} else {
r.buf.WriteString("[ ] ")
}
case *east.Table:
r.renderTable(n, source)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
// Custom Memos nodes
case *mast.TagNode:
if len(n.Source) > 0 {
r.buf.Write(n.Source)
} else {
r.buf.WriteByte('#')
r.buf.Write(n.Tag)
}
case *mast.GFMEmailNode:
r.buf.Write(n.Source)
case *mast.InlineMathNode:
r.buf.Write(n.Source)
case *mast.BlockMathNode:
r.buf.Write(n.Source)
if node.NextSibling() != nil {
if !bytes.HasSuffix(n.Source, []byte{'\n'}) {
r.buf.WriteByte('\n')
}
r.buf.WriteByte('\n')
}
case *mast.MentionNode:
if len(n.Source) > 0 {
r.buf.Write(n.Source)
} else {
r.buf.WriteByte('@')
r.buf.Write(n.Username)
}
default:
// For unknown nodes, try to render children
r.renderChildren(n, source, depth)
}
}
func autoLinkLabelRange(node *gast.AutoLink, source []byte) (int, int, bool) {
label := node.Label(source)
for _, start := range []int{node.Pos(), node.Pos() + 1} {
if start >= 0 && start <= len(source)-len(label) && bytes.HasPrefix(source[start:], label) {
return start, start + len(label), true
}
}
return 0, 0, false
}
// renderChildren renders all children of a node.
func (r *MarkdownRenderer) renderChildren(node gast.Node, source []byte, depth int) {
child := node.FirstChild()
for child != nil {
r.renderNode(child, source, depth+1)
child = child.NextSibling()
}
}
// renderCodeBlock renders a code block.
func (r *MarkdownRenderer) renderCodeBlock(node gast.Node, source []byte) {
if fenced, ok := node.(*gast.FencedCodeBlock); ok {
// Fenced code block with language
r.buf.WriteString("```")
if lang := fenced.Language(source); len(lang) > 0 {
r.buf.Write(lang)
}
r.buf.WriteByte('\n')
// Write all lines
lines := fenced.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.Write(line.Value(source))
}
r.buf.WriteString("```")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
} else if codeBlock, ok := node.(*gast.CodeBlock); ok {
// Indented code block
lines := codeBlock.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.WriteString(" ")
r.buf.Write(line.Value(source))
}
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
}
}
// renderBlockquote renders a blockquote with "> " prefix.
func (r *MarkdownRenderer) renderBlockquote(node *gast.Blockquote, source []byte, depth int) {
// Create a temporary buffer for the blockquote content
tempBuf := &bytes.Buffer{}
tempRenderer := &MarkdownRenderer{buf: tempBuf}
tempRenderer.renderChildren(node, source, depth)
// Add "> " prefix to each line
content := tempBuf.String()
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
for i, line := range lines {
r.buf.WriteString("> ")
r.buf.WriteString(line)
if i < len(lines)-1 {
r.buf.WriteByte('\n')
}
}
}
// renderListItem renders a list item with proper indentation and markers.
func (r *MarkdownRenderer) renderListItem(node *gast.ListItem, source []byte, depth int) {
parent := node.Parent()
list, ok := parent.(*gast.List)
if !ok {
r.renderChildren(node, source, depth)
return
}
// Document=0, List=1, ListItem=2 (no indent), nested ListItem=3+ (indent).
prefix := ""
if depth > 2 {
prefix = strings.Repeat(" ", (depth-2)/2)
}
marker := "- "
if list.IsOrdered() {
marker = fmt.Sprintf("%d. ", list.Start)
list.Start++ // Increment for next item
}
contentBuffer := &bytes.Buffer{}
contentRenderer := &MarkdownRenderer{buf: contentBuffer}
contentRenderer.renderChildren(node, source, depth)
content := contentBuffer.String()
contentLines := strings.Split(content, "\n")
r.buf.WriteString(prefix)
r.buf.WriteString(marker)
r.buf.WriteString(contentLines[0])
continuationIndent := strings.Repeat(" ", len(prefix)+len(marker))
for index, line := range contentLines[1:] {
r.buf.WriteByte('\n')
if line != "" || index < len(contentLines)-2 {
lineIndent := len(line) - len(strings.TrimLeft(line, " "))
if lineIndent < len(continuationIndent) {
r.buf.WriteString(continuationIndent[:len(continuationIndent)-lineIndent])
}
r.buf.WriteString(line)
}
}
// Add newline if there's a next sibling
if node.NextSibling() != nil && !strings.HasSuffix(content, "\n") {
r.buf.WriteByte('\n')
}
}
// renderTable renders a table in markdown format.
func (r *MarkdownRenderer) renderTable(table *east.Table, source []byte) {
// This is a simplified table renderer
// A full implementation would need to handle alignment, etc.
r.renderChildren(table, source, 0)
}