Skip to content

Commit f7ccafc

Browse files
codenemclaude
andcommitted
Add prosemirror package for parsing and rendering Tiptap JSON to HTML
Introduces pkg/prosemirror with Go structs to unmarshal ProseMirror/Tiptap JSON documents and an HTML renderer. Supports all standard node types (headings, paragraphs, lists, tables, code blocks, images, blockquotes) and mark types (bold, italic, underline, strike, code, link). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 260a192 commit f7ccafc

File tree

5 files changed

+1833
-0
lines changed

5 files changed

+1833
-0
lines changed

pkg/prosemirror/html.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
2+
//
3+
// Permission to use, copy, modify, and/or distribute this software for any
4+
// purpose with or without fee is hereby granted, provided that the above
5+
// copyright notice and this permission notice appear in all copies.
6+
//
7+
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8+
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
9+
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10+
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
11+
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
12+
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
13+
// PERFORMANCE OF THIS SOFTWARE.
14+
15+
package prosemirror
16+
17+
import (
18+
"bytes"
19+
"fmt"
20+
"html"
21+
"strconv"
22+
)
23+
24+
// RenderHTML renders a ProseMirror document node tree to an HTML string.
25+
func RenderHTML(node Node) (string, error) {
26+
var buf bytes.Buffer
27+
if err := renderNode(&buf, node); err != nil {
28+
return "", err
29+
}
30+
return buf.String(), nil
31+
}
32+
33+
func renderNode(buf *bytes.Buffer, n Node) error {
34+
switch n.Type {
35+
case NodeDoc:
36+
return renderChildren(buf, n.Content)
37+
case NodeParagraph:
38+
buf.WriteString("<p>")
39+
if err := renderChildren(buf, n.Content); err != nil {
40+
return err
41+
}
42+
buf.WriteString("</p>")
43+
case NodeHeading:
44+
attrs, err := n.HeadingAttrs()
45+
if err != nil {
46+
return fmt.Errorf("cannot render heading node: %w", err)
47+
}
48+
if attrs.Level < 1 || attrs.Level > 6 {
49+
return fmt.Errorf("cannot render heading node: invalid level %d", attrs.Level)
50+
}
51+
level := strconv.Itoa(attrs.Level)
52+
buf.WriteString("<h")
53+
buf.WriteString(level)
54+
buf.WriteByte('>')
55+
if err := renderChildren(buf, n.Content); err != nil {
56+
return err
57+
}
58+
buf.WriteString("</h")
59+
buf.WriteString(level)
60+
buf.WriteByte('>')
61+
case NodeBlockquote:
62+
buf.WriteString("<blockquote>")
63+
if err := renderChildren(buf, n.Content); err != nil {
64+
return err
65+
}
66+
buf.WriteString("</blockquote>")
67+
case NodeCodeBlock:
68+
attrs, err := n.CodeBlockAttrs()
69+
if err != nil {
70+
return fmt.Errorf("cannot render code block node: %w", err)
71+
}
72+
buf.WriteString("<pre><code")
73+
if attrs.Language != nil {
74+
writeAttr(buf, "class", "language-"+*attrs.Language)
75+
}
76+
buf.WriteByte('>')
77+
if err := renderChildren(buf, n.Content); err != nil {
78+
return err
79+
}
80+
buf.WriteString("</code></pre>")
81+
case NodeHorizontalRule:
82+
buf.WriteString("<hr>")
83+
case NodeHardBreak:
84+
buf.WriteString("<br>")
85+
case NodeText:
86+
return renderText(buf, n)
87+
case NodeImage:
88+
attrs, err := n.ImageAttrs()
89+
if err != nil {
90+
return fmt.Errorf("cannot render image node: %w", err)
91+
}
92+
buf.WriteString("<img")
93+
writeAttr(buf, "src", attrs.Src)
94+
if attrs.Alt != nil {
95+
writeAttr(buf, "alt", *attrs.Alt)
96+
}
97+
if attrs.Title != nil {
98+
writeAttr(buf, "title", *attrs.Title)
99+
}
100+
buf.WriteByte('>')
101+
case NodeBulletList:
102+
buf.WriteString("<ul>")
103+
if err := renderChildren(buf, n.Content); err != nil {
104+
return err
105+
}
106+
buf.WriteString("</ul>")
107+
case NodeOrderedList:
108+
attrs, err := n.OrderedListAttrs()
109+
if err != nil {
110+
return fmt.Errorf("cannot render ordered list node: %w", err)
111+
}
112+
buf.WriteString("<ol")
113+
if attrs.Start != 1 {
114+
writeAttr(buf, "start", strconv.Itoa(attrs.Start))
115+
}
116+
if attrs.Type != nil {
117+
writeAttr(buf, "type", *attrs.Type)
118+
}
119+
buf.WriteByte('>')
120+
if err := renderChildren(buf, n.Content); err != nil {
121+
return err
122+
}
123+
buf.WriteString("</ol>")
124+
case NodeListItem:
125+
buf.WriteString("<li>")
126+
if err := renderChildren(buf, n.Content); err != nil {
127+
return err
128+
}
129+
buf.WriteString("</li>")
130+
case NodeTable:
131+
buf.WriteString("<table>")
132+
if err := renderChildren(buf, n.Content); err != nil {
133+
return err
134+
}
135+
buf.WriteString("</table>")
136+
case NodeTableRow:
137+
buf.WriteString("<tr>")
138+
if err := renderChildren(buf, n.Content); err != nil {
139+
return err
140+
}
141+
buf.WriteString("</tr>")
142+
case NodeTableCell:
143+
return renderTableCell(buf, n, "td")
144+
case NodeTableHeader:
145+
return renderTableCell(buf, n, "th")
146+
default:
147+
return fmt.Errorf("cannot render node: unknown type %q", n.Type)
148+
}
149+
return nil
150+
}
151+
152+
func renderChildren(buf *bytes.Buffer, nodes []Node) error {
153+
for _, child := range nodes {
154+
if err := renderNode(buf, child); err != nil {
155+
return err
156+
}
157+
}
158+
return nil
159+
}
160+
161+
func renderText(buf *bytes.Buffer, n Node) error {
162+
if n.Text == nil {
163+
return fmt.Errorf("cannot render text node: text is nil")
164+
}
165+
for _, m := range n.Marks {
166+
if err := openMark(buf, m); err != nil {
167+
return err
168+
}
169+
}
170+
buf.WriteString(html.EscapeString(*n.Text))
171+
for i := len(n.Marks) - 1; i >= 0; i-- {
172+
closeMark(buf, n.Marks[i])
173+
}
174+
return nil
175+
}
176+
177+
func openMark(buf *bytes.Buffer, m Mark) error {
178+
switch m.Type {
179+
case MarkStrong:
180+
buf.WriteString("<strong>")
181+
case MarkEm:
182+
buf.WriteString("<em>")
183+
case MarkUnderline:
184+
buf.WriteString("<u>")
185+
case MarkStrike:
186+
buf.WriteString("<s>")
187+
case MarkCode:
188+
buf.WriteString("<code>")
189+
case MarkLink:
190+
attrs, err := m.LinkAttrs()
191+
if err != nil {
192+
return fmt.Errorf("cannot render link mark: %w", err)
193+
}
194+
buf.WriteString("<a")
195+
writeAttr(buf, "href", attrs.Href)
196+
if attrs.Target != nil {
197+
writeAttr(buf, "target", *attrs.Target)
198+
}
199+
if attrs.Rel != nil {
200+
writeAttr(buf, "rel", *attrs.Rel)
201+
}
202+
if attrs.Class != nil {
203+
writeAttr(buf, "class", *attrs.Class)
204+
}
205+
if attrs.Title != nil {
206+
writeAttr(buf, "title", *attrs.Title)
207+
}
208+
buf.WriteByte('>')
209+
default:
210+
return fmt.Errorf("cannot render mark: unknown type %q", m.Type)
211+
}
212+
return nil
213+
}
214+
215+
func closeMark(buf *bytes.Buffer, m Mark) {
216+
switch m.Type {
217+
case MarkStrong:
218+
buf.WriteString("</strong>")
219+
case MarkEm:
220+
buf.WriteString("</em>")
221+
case MarkUnderline:
222+
buf.WriteString("</u>")
223+
case MarkStrike:
224+
buf.WriteString("</s>")
225+
case MarkCode:
226+
buf.WriteString("</code>")
227+
case MarkLink:
228+
buf.WriteString("</a>")
229+
}
230+
}
231+
232+
func renderTableCell(buf *bytes.Buffer, n Node, tag string) error {
233+
attrs, err := n.TableCellAttrs()
234+
if err != nil {
235+
return fmt.Errorf("cannot render %s node: %w", tag, err)
236+
}
237+
buf.WriteByte('<')
238+
buf.WriteString(tag)
239+
if attrs.Colspan > 1 {
240+
writeAttr(buf, "colspan", strconv.Itoa(attrs.Colspan))
241+
}
242+
if attrs.Rowspan > 1 {
243+
writeAttr(buf, "rowspan", strconv.Itoa(attrs.Rowspan))
244+
}
245+
if len(attrs.Colwidth) > 0 {
246+
total := 0
247+
for _, w := range attrs.Colwidth {
248+
total += w
249+
}
250+
writeAttr(buf, "style", "min-width: "+strconv.Itoa(total)+"px")
251+
}
252+
buf.WriteByte('>')
253+
if err := renderChildren(buf, n.Content); err != nil {
254+
return err
255+
}
256+
buf.WriteString("</")
257+
buf.WriteString(tag)
258+
buf.WriteByte('>')
259+
return nil
260+
}
261+
262+
func writeAttr(buf *bytes.Buffer, name, value string) {
263+
buf.WriteByte(' ')
264+
buf.WriteString(name)
265+
buf.WriteString(`="`)
266+
buf.WriteString(html.EscapeString(value))
267+
buf.WriteByte('"')
268+
}

0 commit comments

Comments
 (0)