Integration Markdown in Custom Extensions
This guide shows you how to add Markdown support to your Tiptap extensions.
Tip: For standard patterns like Pandoc blocks (
:::name
) or shortcodes ([name]
), check out the Utility Functions to generate Markdown specs with minimal code.
Basic Extension Integration
To add Markdown support to an extension, define a Markdown configuration in your extension configuration:
import { Node } from '@tiptap/core'
const MyNode = Node.create({
name: 'myNode',
// ... other configuration (parseHTML, renderHTML, etc.)
parseMarkdown: (token, helpers) => {
/* ... */
},
renderMarkdown: (node, helpers) => {
/* ... */
},
})
The Markdown Configuration Explained
The extension spec allows for the following options:
markdownTokenName
- The token name to handle. Required if the token name differs from the extension name.parseMarkdown
- The function that converts tokens into Tiptap JSON.renderMarkdown
- The function that converts Tiptap JSON into Markdown strings.markdownTokenizer
- A custom tokenizer to recognize new Markdown syntax.markdownOptions
- A set of options to configure the tokenizer.indentsContent
- Controls if this node increases the indent level for nested content (e.g., lists).
Using markdownTokenName
markdownTokenName: 'strong' // for a Bold mark extension where the Markdown token is called "strong"
Node Extensions
Creating Markdown support for nodes is straightforward. Here are some common patterns.
Depending on the type of content you expect, you may need to use different helper functions in your parseMarkdown
and renderMarkdown
methods.
- If your extension is a node with block-level content, use
helpers.parseChildren
andhelpers.renderChildren
. - If your extension is a node with inline-level content, use
helpers.parseInline
andhelpers.renderChildren
.
import { Node } from '@tiptap/core'
const Heading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
parseHTML() {
return [{ tag: 'p' }]
},
renderHTML() {
return ['p', 0]
},
parseMarkdown: (token, helpers) => {
const level = token.depth || 1
const content = helpers.parseInline(token.tokens || []) // we parse inline here because headings contain inline content
return {
type: 'heading',
attrs: { level },
content,
}
},
renderMarkdown: (node, helpers) => {
const level = node.attrs?.level || 1
const prefix = '#'.repeat(level)
const content = helpers.renderChildren(node.content || [])
return `${prefix} ${content}`
},
})
Mark Extensions
Marks work differently because they wrap inline content. To add Markdown support to your mark extensions, use the applyMark
and renderChildren
helper functions.
import { Mark } from '@tiptap/core'
const Bold = Mark.create({
name: 'bold',
parseHTML() {
return [{ tag: 'strong' }, { tag: 'b' }]
},
renderHTML() {
return ['strong', 0]
},
markdownTokenName: 'strong',
parseMarkdown: (token, helpers) => {
// Parse the content and apply the bold mark
const content = helpers.parseInline(token.tokens || [])
return helpers.applyMark('bold', content)
},
renderMarkdown: (node, helpers) => {
const content = helpers.renderChildren(node.content || [])
return `**${content}**`
},
})
If you want to apply attributes to your marks, use the applyMark
helper with an attributes object.
const content = helpers.applyMark('link', helpers.parseInline(token.tokens || []), {
href: token.href || '',
title: token.title || null,
})
Testing Your Extension
Unit Test Parse Handler
import { describe, it, expect } from 'vitest'
describe('Heading Markdown', () => {
it('should parse heading tokens', () => {
const token = {
type: 'heading',
depth: 2,
tokens: [{ type: 'text', text: 'Hello' }],
}
const helpers = {
parseInline: tokens => [{ type: 'text', text: 'Hello' }],
}
const result = Heading.configuration.parseMarkdown(token, helpers)
expect(result).toEqual({
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Hello' }],
})
})
})
Unit Test Render Handler
import { describe, it, expect } from 'vitest'
describe('Heading Markdown', () => {
it('should render heading nodes', () => {
const node = {
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Hello' }],
}
const helpers = {
renderChildren: () => 'Hello',
}
const result = Heading.configuration.renderMarkdown(node, helpers, {})
expect(result).toBe('## Hello\n\n')
})
})
Integration Test
import { describe, it, expect } from 'vitest'
import { Editor } from '@tiptap/core'
import { Markdown } from '@tiptap/markdown'
import MyExtension from './MyExtension'
describe('MyExtension integration', () => {
it('should parse and serialize correctly', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, MyExtension, Markdown],
})
const markdown = '# Hello World'
editor.commands.setContent(markdown, { contentType: 'markdown' })
const json = editor.getJSON()
expect(json.content[0].type).toBe('heading')
const result = editor.getMarkdown()
expect(result).toBe('# Hello World\n\n')
editor.destroy()
})
})
Common Patterns
Handle Optional Token Properties
parse: (token, helpers) => {
return {
type: 'myNode',
attrs: {
level: token.depth || 1, // Default if missing
id: token.id ?? null, // Nullish coalescing
text: token.text?.trim() || '', // Optional chaining
},
content: helpers.parseInline(token.tokens || []),
}
}
Conditional Parsing
parse: (token, helpers) => {
// Only handle specific types
if (token.ordered) {
return null // Let another handler process it
}
// Or use different logic based on token properties
if (token.depth > 6) {
// Treat as paragraph instead
return {
type: 'paragraph',
content: helpers.parseInline(token.tokens || []),
}
}
return {
type: 'heading',
attrs: { level: token.depth },
content: helpers.parseInline(token.tokens || []),
}
}
Context-Aware Rendering
render: (node, helpers, context) => {
const content = helpers.renderChildren(node.content || [])
// Adjust rendering based on context
if (context.level > 0) {
// Nested - add extra indentation
return helpers.indent(`- ${content}`) + '\n'
}
// Top-level
return `- ${content}\n`
}