// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. package prosemirror import ( "bytes" "fmt" "strconv" "strings" ) // RenderMarkdown renders a ProseMirror document node tree to a Markdown string. func RenderMarkdown(node Node) (string, error) { r := &mdRenderer{ buf: &bytes.Buffer{}, atLineStart: true, } if err := r.renderNode(node); err != nil { return "", err } out := strings.TrimRight(r.buf.String(), "\n") if out != "" { out += "\n" } return out, nil } type mdRenderer struct { buf *bytes.Buffer prefix string tight bool atLineStart bool } func (r *mdRenderer) ensurePrefix() { if r.atLineStart { r.buf.WriteString(r.prefix) r.atLineStart = false } } func (r *mdRenderer) newLine() { r.buf.WriteByte('\n') r.atLineStart = true } func (r *mdRenderer) renderNode(n Node) error { switch n.Type { case NodeDoc: return r.renderBlocks(n.Content) case NodeParagraph: r.ensurePrefix() if err := r.renderInline(n.Content); err != nil { return err } r.newLine() case NodeHeading: attrs, err := n.HeadingAttrs() if err != nil { return fmt.Errorf("cannot render heading node: %w", err) } if attrs.Level < 1 || attrs.Level > 6 { return fmt.Errorf("cannot render heading node: invalid level %d", attrs.Level) } r.ensurePrefix() for i := 0; i < attrs.Level; i++ { r.buf.WriteByte('#') } r.buf.WriteByte(' ') if err := r.renderInline(n.Content); err != nil { return err } r.newLine() case NodeBlockquote: oldPrefix := r.prefix r.prefix += "> " if err := r.renderBlocks(n.Content); err != nil { r.prefix = oldPrefix return err } r.prefix = oldPrefix case NodeCodeBlock: attrs, err := n.CodeBlockAttrs() if err != nil { return fmt.Errorf("cannot render code block node: %w", err) } code := collectText(n.Content) fence := chooseFence(code) r.ensurePrefix() r.buf.WriteString(fence) if attrs.Language != nil { r.buf.WriteString(*attrs.Language) } r.newLine() for line := range strings.SplitSeq(code, "\n") { r.ensurePrefix() r.buf.WriteString(line) r.newLine() } r.ensurePrefix() r.buf.WriteString(fence) r.newLine() case NodeHorizontalRule: r.ensurePrefix() r.buf.WriteString("---") r.newLine() case NodeHardBreak: r.buf.WriteByte('\\') r.newLine() case NodeText: return r.renderText(n) case NodeImage: attrs, err := n.ImageAttrs() if err != nil { return fmt.Errorf("cannot render image node: %w", err) } r.ensurePrefix() r.buf.WriteString("![") if attrs.Alt != nil { r.buf.WriteString(escapeMarkdown(*attrs.Alt)) } r.buf.WriteString("](") r.buf.WriteString(safeImageSrc(attrs.Src)) if attrs.Title != nil { r.buf.WriteString(` "`) r.buf.WriteString(strings.ReplaceAll(*attrs.Title, `"`, `\"`)) r.buf.WriteByte('"') } r.buf.WriteByte(')') case NodeBulletList: return r.renderBulletList(n) case NodeOrderedList: return r.renderOrderedList(n) case NodeListItem: return fmt.Errorf("cannot render list item outside of list context") case NodeTable: return r.renderTable(n) case NodeTableRow, NodeTableCell, NodeTableHeader: return fmt.Errorf("cannot render %s outside of table context", n.Type) default: return fmt.Errorf("cannot render node: unknown type %q", n.Type) } return nil } func (r *mdRenderer) renderBlocks(nodes []Node) error { for i, n := range nodes { if i > 0 && !r.tight { r.ensurePrefix() r.newLine() } if err := r.renderNode(n); err != nil { return err } } return nil } func (r *mdRenderer) renderInline(nodes []Node) error { for _, n := range nodes { if err := r.renderNode(n); err != nil { return err } } return nil } func (r *mdRenderer) renderText(n Node) error { if n.Text == nil { return fmt.Errorf("cannot render text node: text is nil") } r.ensurePrefix() if len(n.Marks) == 0 { r.buf.WriteString(escapeMarkdown(*n.Text)) return nil } var hasCode bool for _, m := range n.Marks { if m.Type == MarkCode { hasCode = true break } } if hasCode { return r.renderCodeText(n) } text := *n.Text var needsTrim bool for _, m := range n.Marks { switch m.Type { case MarkStrong, MarkEm, MarkStrike: needsTrim = true } } var leading, trailing string if needsTrim { origLen := len(text) text = strings.TrimLeft(text, " ") leading = strings.Repeat(" ", origLen-len(text)) origLen = len(text) text = strings.TrimRight(text, " ") trailing = strings.Repeat(" ", origLen-len(text)) } if text == "" { r.buf.WriteString(leading) r.buf.WriteString(trailing) return nil } r.buf.WriteString(leading) for _, m := range n.Marks { if err := r.openMark(m); err != nil { return err } } r.buf.WriteString(escapeMarkdown(text)) for i := len(n.Marks) - 1; i >= 0; i-- { if err := r.closeMark(n.Marks[i]); err != nil { return err } } r.buf.WriteString(trailing) return nil } // maxConsecutiveBackticks returns the length of the longest run of '`' in s. // Inline code fences must be longer than this value (CommonMark). func maxConsecutiveBackticks(s string) int { max, cur := 0, 0 for i := 0; i < len(s); i++ { if s[i] == '`' { cur++ if cur > max { max = cur } } else { cur = 0 } } return max } func (r *mdRenderer) renderCodeText(n Node) error { text := *n.Text fence := strings.Repeat("`", maxConsecutiveBackticks(text)+1) var otherMarks []Mark for _, m := range n.Marks { if m.Type != MarkCode { otherMarks = append(otherMarks, m) } } for _, m := range otherMarks { if err := r.openMark(m); err != nil { return err } } r.buf.WriteString(fence) if len(fence) > 1 { r.buf.WriteByte(' ') } r.buf.WriteString(text) if len(fence) > 1 { r.buf.WriteByte(' ') } r.buf.WriteString(fence) for i := len(otherMarks) - 1; i >= 0; i-- { if err := r.closeMark(otherMarks[i]); err != nil { return err } } return nil } func (r *mdRenderer) openMark(m Mark) error { switch m.Type { case MarkStrong: r.buf.WriteString("**") case MarkEm: r.buf.WriteByte('*') case MarkUnderline: r.buf.WriteString("") case MarkStrike: r.buf.WriteString("~~") case MarkLink: r.buf.WriteByte('[') default: return fmt.Errorf("cannot render mark: unknown type %q", m.Type) } return nil } func (r *mdRenderer) closeMark(m Mark) error { switch m.Type { case MarkStrong: r.buf.WriteString("**") case MarkEm: r.buf.WriteByte('*') case MarkUnderline: r.buf.WriteString("") case MarkStrike: r.buf.WriteString("~~") case MarkLink: attrs, err := m.LinkAttrs() if err != nil { return fmt.Errorf("cannot render link mark: %w", err) } r.buf.WriteString("](") r.buf.WriteString(safeLinkHref(attrs.Href)) if attrs.Title != nil { r.buf.WriteString(` "`) r.buf.WriteString(strings.ReplaceAll(*attrs.Title, `"`, `\"`)) r.buf.WriteByte('"') } r.buf.WriteByte(')') default: return fmt.Errorf("cannot render mark: unknown type %q", m.Type) } return nil } func (r *mdRenderer) renderBulletList(n Node) error { tight, err := listTightness(n) if err != nil { return fmt.Errorf("cannot render bullet list: %w", err) } for i, item := range n.Content { if i > 0 && !tight { r.ensurePrefix() r.newLine() } r.ensurePrefix() r.buf.WriteString("- ") r.atLineStart = false oldPrefix := r.prefix oldTight := r.tight r.prefix += " " r.tight = tight if err := r.renderBlocks(item.Content); err != nil { r.prefix = oldPrefix r.tight = oldTight return err } r.prefix = oldPrefix r.tight = oldTight } return nil } func (r *mdRenderer) renderOrderedList(n Node) error { attrs, err := n.OrderedListAttrs() if err != nil { return fmt.Errorf("cannot render ordered list node: %w", err) } tight, err := listTightness(n) if err != nil { return fmt.Errorf("cannot render ordered list: %w", err) } start := max(attrs.Start, 1) for i, item := range n.Content { if i > 0 && !tight { r.ensurePrefix() r.newLine() } r.ensurePrefix() num := strconv.Itoa(start + i) r.buf.WriteString(num) r.buf.WriteString(". ") r.atLineStart = false indent := strings.Repeat(" ", len(num)+2) oldPrefix := r.prefix oldTight := r.tight r.prefix += indent r.tight = tight if err := r.renderBlocks(item.Content); err != nil { r.prefix = oldPrefix r.tight = oldTight return err } r.prefix = oldPrefix r.tight = oldTight } return nil } func (r *mdRenderer) renderTable(n Node) error { return r.renderGFMTable(n) } func (r *mdRenderer) renderGFMTable(n Node) error { if len(n.Content) == 0 { return nil } headerRow := n.Content[0] r.ensurePrefix() r.buf.WriteByte('|') for _, cell := range headerRow.Content { r.buf.WriteByte(' ') if err := r.renderCellInline(cell); err != nil { return err } r.buf.WriteString(" |") } r.newLine() r.ensurePrefix() r.buf.WriteByte('|') for range headerRow.Content { r.buf.WriteString(" --- |") } r.newLine() for _, row := range n.Content[1:] { r.ensurePrefix() r.buf.WriteByte('|') for _, cell := range row.Content { r.buf.WriteByte(' ') if err := r.renderCellInline(cell); err != nil { return err } r.buf.WriteString(" |") } r.newLine() } return nil } func (r *mdRenderer) renderCellInline(cell Node) error { if len(cell.Content) == 1 && cell.Content[0].Type == NodeParagraph { return r.renderInline(cell.Content[0].Content) } for _, child := range cell.Content { h, err := RenderHTML(child) if err != nil { return fmt.Errorf("cannot render table cell content: %w", err) } r.buf.WriteString(strings.ReplaceAll(h, "|", `\|`)) } return nil } // listTightness returns true when the list is tight (no blank lines between items). // Every direct child of n must be a listItem; otherwise listTightness returns an error. func listTightness(n Node) (tight bool, err error) { tight = true for _, item := range n.Content { if item.Type != NodeListItem { return false, fmt.Errorf( "invalid child type %q (expected %q)", item.Type, NodeListItem, ) } if len(item.Content) != 1 { tight = false } } return tight, nil } func collectText(nodes []Node) string { var buf strings.Builder for _, n := range nodes { if n.Text != nil { buf.WriteString(*n.Text) } } return buf.String() } func chooseFence(code string) string { fence := "```" for strings.Contains(code, fence) { fence += "`" } return fence } func escapeMarkdown(s string) string { var buf strings.Builder buf.Grow(len(s)) for _, c := range s { switch c { case '\\', '*', '_', '`', '[', ']', '~', '|', '<': buf.WriteByte('\\') } buf.WriteRune(c) } return buf.String() }