// Copyright (c) 2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. package prosemirror import ( "bytes" "fmt" "html" "net/url" "strconv" "strings" ) // RenderHTML renders a ProseMirror document node tree to an HTML string. func RenderHTML(node Node) (string, error) { var buf bytes.Buffer if err := renderNode(&buf, node); err != nil { return "", err } return buf.String(), nil } func renderNode(buf *bytes.Buffer, n Node) error { switch n.Type { case NodeDoc: return renderChildren(buf, n.Content) case NodeParagraph: buf.WriteString("

") if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("

") 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) } level := strconv.Itoa(attrs.Level) buf.WriteString("') if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("') case NodeBlockquote: buf.WriteString("
") if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("
") case NodeCodeBlock: attrs, err := n.CodeBlockAttrs() if err != nil { return fmt.Errorf("cannot render code block node: %w", err) } if attrs.Language != nil && *attrs.Language == "mermaid" { buf.WriteString(`
`)
			if err := renderChildren(buf, n.Content); err != nil {
				return err
			}
			buf.WriteString("
") } else { buf.WriteString("
')
			if err := renderChildren(buf, n.Content); err != nil {
				return err
			}
			buf.WriteString("
") } case NodeHorizontalRule: buf.WriteString("
") case NodeHardBreak: buf.WriteString("
") case NodeText: return renderText(buf, n) case NodeImage: attrs, err := n.ImageAttrs() if err != nil { return fmt.Errorf("cannot render image node: %w", err) } buf.WriteString("') case NodeBulletList: buf.WriteString("") case NodeOrderedList: attrs, err := n.OrderedListAttrs() if err != nil { return fmt.Errorf("cannot render ordered list node: %w", err) } buf.WriteString("') if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("") case NodeListItem: buf.WriteString("
  • ") if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("
  • ") case NodeTable: buf.WriteString("") if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("
    ") case NodeTableRow: buf.WriteString("") if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("") case NodeTableCell: return renderTableCell(buf, n, "td") case NodeTableHeader: return renderTableCell(buf, n, "th") default: return fmt.Errorf("cannot render node: unknown type %q", n.Type) } return nil } func renderChildren(buf *bytes.Buffer, nodes []Node) error { for _, child := range nodes { if err := renderNode(buf, child); err != nil { return err } } return nil } func renderText(buf *bytes.Buffer, n Node) error { if n.Text == nil { return fmt.Errorf("cannot render text node: text is nil") } for _, m := range n.Marks { if err := openMark(buf, m); err != nil { return err } } buf.WriteString(html.EscapeString(*n.Text)) for i := len(n.Marks) - 1; i >= 0; i-- { closeMark(buf, n.Marks[i]) } return nil } func openMark(buf *bytes.Buffer, m Mark) error { switch m.Type { case MarkStrong: buf.WriteString("") case MarkEm: buf.WriteString("") case MarkUnderline: buf.WriteString("") case MarkStrike: buf.WriteString("") case MarkCode: buf.WriteString("") case MarkLink: attrs, err := m.LinkAttrs() if err != nil { return fmt.Errorf("cannot render link mark: %w", err) } buf.WriteString("') default: return fmt.Errorf("cannot render mark: unknown type %q", m.Type) } return nil } func closeMark(buf *bytes.Buffer, m Mark) { switch m.Type { case MarkStrong: buf.WriteString("") case MarkEm: buf.WriteString("") case MarkUnderline: buf.WriteString("") case MarkStrike: buf.WriteString("") case MarkCode: buf.WriteString("") case MarkLink: buf.WriteString("") } } func renderTableCell(buf *bytes.Buffer, n Node, tag string) error { attrs, err := n.TableCellAttrs() if err != nil { return fmt.Errorf("cannot render %s node: %w", tag, err) } buf.WriteByte('<') buf.WriteString(tag) if attrs.Colspan > 1 { writeAttr(buf, "colspan", strconv.Itoa(attrs.Colspan)) } if attrs.Rowspan > 1 { writeAttr(buf, "rowspan", strconv.Itoa(attrs.Rowspan)) } if len(attrs.Colwidth) > 0 { total := 0 for _, w := range attrs.Colwidth { total += w } writeAttr(buf, "style", "min-width: "+strconv.Itoa(total)+"px") } buf.WriteByte('>') if err := renderChildren(buf, n.Content); err != nil { return err } buf.WriteString("') return nil } const linkRelBlankTargetDefault = "noopener noreferrer" // linkRelToEmit returns the rel attribute value for a link mark, or empty when // the attribute should be omitted. When target opens a new browsing context // (_blank), noopener is always injected to prevent the opened page from // accessing window.opener, even when the document supplies a custom rel. func linkRelToEmit(attrs LinkAttrs) string { blanksTarget := attrs.Target != nil && strings.EqualFold(strings.TrimSpace(*attrs.Target), "_blank") if attrs.Rel != nil { if s := strings.TrimSpace(*attrs.Rel); s != "" { if blanksTarget { return ensureNoopener(s) } return s } } if blanksTarget { return linkRelBlankTargetDefault } return "" } // ensureNoopener returns rel unchanged when it already contains the noopener // token (case-insensitive check). Otherwise it appends " noopener". func ensureNoopener(rel string) string { for tok := range strings.FieldsSeq(rel) { if strings.EqualFold(tok, "noopener") { return rel } } return rel + " noopener" } func writeAttr(buf *bytes.Buffer, name, value string) { buf.WriteByte(' ') buf.WriteString(name) buf.WriteString(`="`) buf.WriteString(html.EscapeString(value)) buf.WriteByte('"') } // safeLinkHref returns a value safe to use in link mark attrs and to emit in an // HTML href attribute. URLs with disallowed schemes (for example javascript: or // data:) are replaced with "#" so escaped text content cannot be combined with an // executable URL. func safeLinkHref(href string) string { href = strings.TrimSpace(href) if href == "" { return "#" } if href[0] == '#' { return href } if strings.HasPrefix(href, "/") { if len(href) > 1 && (href[1] == '/' || href[1] == '\\') { return "#" } return href } u, err := url.Parse(href) if err != nil { return "#" } if u.Scheme != "" { switch strings.ToLower(u.Scheme) { case "http", "https", "mailto", "tel": return href default: return "#" } } if u.Host != "" { return "#" } return href } // safeImageSrc returns a value safe to use in an HTML img src attribute. // Only http, https, and data schemes are permitted; everything else // (javascript:, vbscript:, etc.) is replaced with an empty string so the // image simply does not render. func safeImageSrc(src string) string { src = strings.TrimSpace(src) if src == "" { return "" } if strings.HasPrefix(src, "/") { if len(src) > 1 && (src[1] == '/' || src[1] == '\\') { return "" } return src } u, err := url.Parse(src) if err != nil { return "" } if u.Scheme != "" { switch strings.ToLower(u.Scheme) { case "http", "https", "data": return src default: return "" } } if u.Host != "" { return "" } return src }