Inside ClarityBoss, we use Tiptap as our editor for all journal entries. This allows for easy rich text formatting, as well as input from and output to Markdown and HTML.
We’ve found it to be a great fit for us so far. It lets us focus on what we actually want to build, rather than reinventing the wheel of yet another text editor. It also has great extensibility, such as enabling us to do @Person style mentions by just implementing a single function that provides suggestions.
However, unlike probably 90% of people using Tiptap, we have a backend that is written with Go, not Typescript running in Node. We sometimes need to understand the Tiptap content server side, whether it be for email notifications or gathering unfinished tasks. Thus, we’ve had to do a few things differently since we can’t just import Tiptap and use it server-side.
Tiptap Content Unmarshalling in Go
Most of the time, we just treat the content from Tiptap as a generic JSON blob. This means we can store it in a JSONB column in Postgres, and don’t need to worry about the shape of it. We’re also not currently trying to do anything super fancy with CRDTs or realtime conflict resolution, since journal entries are currently private to a given ClarityBoss user.
When and if we do need to unpack it, the corresponding struct definitions are pretty simple. JSONContent is recursive, and Mark is the only other type needed other than primitive types for the various fields.
// Mark represents a text mark in TipTap editor.
// This mirrors the Typescript type.
type Mark struct {
Type string `json:"type"`
Attrs map[string]any `json:"attrs,omitempty"`
}
// JSONContent represents the structure of the content in TipTap editor.
// This mirrors the Typescript type.
type JSONContent struct {
Type string `json:"type,omitempty"`
Attrs map[string]any `json:"attrs,omitempty"`
Content []JSONContent `json:"content,omitempty"`
Marks []Mark `json:"marks,omitempty"`
Text *string `json:"text,omitempty"`
}
What are some of the reasons you might want to have well-typed Tiptap content in Go code?
- We use the Tiptap
TaskListextension, combined withUniqueID. The Go struct types make it easy to walk the content, look for all unfinished tasks, and gather them with their unique IDs. - We have our own custom “labeled block” extension we use for helping with delivering feedback. We’re able to programmatically build up the structure for creating a new feedback journal entry using helper functions (e.g.
labeledBlockandparagraph) and be confident our Tiptap content structure is valid. - Having strong types makes it a lot more clear what type of content one has, or what is being returned from a method. Having the type ensures developers (or LLMs) aren’t confused whether we are dealing with the JSON representation, some different JSON, Markdown, or whatever else.
Tiptap Render and Parse Sidecar Container
At first, we wrote a function in Go that attempted to extract the plain text content from a Tiptap blob. However, this quickly became untenable, and not very reliable, and extremely messy. There were a lot of hacks to try to ensure newlines were in the right place, but we didn’t want 3 empty lines in a row. Once lists, task items, and other blocks started to come into the picture, the function became a huge mess.
So instead, we waved the white flag of surrender- just let Tiptap do it for us! Using fastify, we added a sidecar container to our Golang backend deployment that does the things that we can’t do in Go without rewriting code from a different language.
The dependencies of this sidecar are minimal. fastify is used for the API layer, which uses ajv for request validation. jsdom is used to provide a DOM-like backend that Tiptap can use for rendering and parsing. We also throw dompurify in, which I’ll touch on a bit later.
Our entire codebase is in a single repo, so we can share a list of Tiptap extensions between the frontend and backend, to ensure the known extensions and document schema stay in sync.
The sidecar application has just a handful of stateless endpoints, with no authentication, since it isn’t publicly routable or accessible. A call to the sidecar only takes 1-4 milliseconds on average, since it is running colocated with the Go backend.
POST /render-tiptap
This endpoint takes in a Tiptap document, and returns a { html: "<h1>Hello!</h1>", markdown: "# Hello!" } object. We dumped our home-rolled Go function for extracting text completely in favor of a quick call to this endpoint, and can be a lot more confident it is going to render things correctly.
// call tiptap `getSchema` function with your extensions to get a schema
const domSerializer = DOMSerializer.fromSchema(schema)
const { window } = new JSDOM('')
app.post<{ Body: JSONContent }>(
'/render-tiptap',
// jsonContent schema was preregistered with this id
{ schema: { body: { $ref: 'jsonContent#' } } },
async (request, reply) => {
const doc = request.body
try {
const node = Node.fromJSON(schema, doc)
const document = window.document.implementation.createHTMLDocument()
const output = domSerializer.serializeFragment(node.content, { document })
document.body.appendChild(output)
return {
html: document.body.innerHTML,
markdown: isEmptyOrDefaultText(doc) ? '' : markdownManager.serialize(doc),
}
} catch (e) {
app.log.error({ error: (e as Error).message }, 'error processing JSON content')
reply.code(400)
return { error: 'Bad Request', details: { message: (e as Error).message } }
}
},
)
POST /markdown-to-tiptap
Sometimes we have human (or LLM) input in the form of Markdown, but need to convert it to Tiptap for storage. This endpoint takes in the plain text Markdown, and emits a Tiptap document.
We found this really useful for creating a sample journal entry highlighting some of the ClarityBoss features. Rather than having to maintain a journal entry template in JSON that would be tedious for a human to write, we can maintain the template in Markdown and parse it to Tiptap structure when needed.
const markdownManager = new MarkdownManager({ extensions })
app.post<{ Body: { markdown: string } }>(
'/markdown-to-tiptap',
{
schema: {
body: {
type: 'object',
properties: {
markdown: { type: 'string' },
},
required: ['markdown'],
},
},
},
async (request, reply) => {
const { markdown } = request.body
try {
const jsonContent = markdownManager.parse(markdown)
return { tiptap: jsonContent }
} catch (e) {
app.log.error({ error: (e as Error).message }, 'error processing markdown content')
reply.code(400)
return { error: 'Bad Request', details: { message: (e as Error).message } }
}
},
)
POST /sanitize-html
We added this endpoint after already having the sidecar in place. It takes in a string of HTML content, and returns a sanitized string of HTML content.
Under the hood, it uses DOMPurify. While DOMPurify has had some security issues, it also has the other things you want from a project- eyeballs, a security policy, a bug bounty, and it continues to be a highly active and maintained project.
There doesn’t seem to be a good Go-native solution for HTML sanitization right now. bluemonday is mentioned as the best option, but unfortunately, it hasn’t really seen active development since July 2024.
app.post<{ Body: { html: string } }>(
'/sanitize-html',
{
schema: {
body: {
type: 'object',
properties: {
html: { type: 'string' },
},
required: ['html'],
},
},
},
async (request) => {
const { html } = request.body
const sanitized = purify.sanitize(html, { FORBID_ATTR: ['class', 'style'] })
return { sanitized }
},
)
Wrapping Up
With the Node-based Tiptap sidecar container in place, and some minimal struct definitions in Go, we haven’t had any additional impedance mismatches to work through in a while. If needed, it’s trivial to add additional endpoints to the sidecar. Adding POST /html-to-tiptap likely wouldn’t be more than a handful of lines of new code, as long as the functionality is in the underlying Tiptap libraries.