Create a Highlight Mark with Markdown Support
This guide walks through adding Markdown support for a small inline highlight
mark that uses the ==text==
shorthand (common in some Markdown flavors) to produce a mark
element in HTML.
We'll follow four clear steps and at each step include a full example so you always have the complete context:
- Create the basic Tiptap
Mark
extension (without Markdown support). - Add a custom Markdown tokenizer to produce tokens from the raw Markdown.
- Add a parser that converts those tokens into Tiptap JSON (applying the mark).
- Add a renderer (serializer) that converts the Tiptap content back to Markdown.
Example shorthand we'll support:
This is ==highlighted== text.
Step 1: Create the basic highlight
Mark
Start with a minimal Mark
definition that describes the mark name, HTML parse/render behavior and any options. Keep Markdown integration out for now so you can focus on schema and HTML input/output first.
import { Mark } from '@tiptap/core'
export const Highlight = Mark.create({
name: 'highlight',
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0]
},
addCommands() {
return {
toggleHighlight:
() =>
({ commands }) => {
return commands.toggleMark(this.name)
},
}
},
})
Notes:
- This mark simply maps to the HTML
<mark>
tag. - The command
toggleHighlight
allows toggling the mark programmatically.
Step 2: Add a custom Markdown tokenizer
The tokenizer is responsible for recognizing ==text==
in the raw Markdown and returning a token containing the inner text and any nested inline tokens. Keep this step focused on the tokenizer so you can test recognition independently.
import { Mark } from '@tiptap/core'
export const Highlight = Mark.create({
name: 'highlight',
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0]
},
// define a custom Markdown tokenizer to recognize ==text==
markdownTokenizer: {
name: 'highlight',
level: 'inline', // inline element
// fast hint for the lexer to find candidate positions
start: (src) => src.indexOf('=='),
tokenize: (src, tokens, lexer) => {
// Match ==text== at the start of the remaining source
const match = /^==([^=]+)==/.exec(src)
if (!match) return undefined
return {
type: 'highlight', // token type (must match name)
raw: match[0], // full matched string: ==text==
text: match[1], // inner content: text
// Let the Markdown lexer process nested inline formatting
tokens: lexer.inlineTokens(match[1]),
}
},
},
addCommands() {
return {
toggleHighlight:
() =>
({ commands }) => {
return commands.toggleMark(this.name)
},
}
},
})
Implementation notes:
start
is an optimization used by the lexer to quickly locate potential matches.- The tokenizer returns
tokens: lexer.inlineTokens(match[1])
so nested inline formatting (bold, emphasis, links, etc.) is preserved.
Step 3: Add the parser
The parseMarkdown
function converts the token produced by the tokenizer into a Tiptap representation. For marks, you'll typically parse the inner tokens into inline nodes and then apply the mark to that content.
import { Mark } from '@tiptap/core'
export const Highlight = Mark.create({
name: 'highlight',
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0]
},
// define a custom Markdown tokenizer to recognize ==text==
markdownTokenizer: {
name: 'highlight',
level: 'inline', // inline element
// fast hint for the lexer to find candidate positions
start: (src) => src.indexOf('=='),
tokenize: (src, tokens, lexer) => {
// Match ==text== at the start of the remaining source
const match = /^==([^=]+)==/.exec(src)
if (!match) return undefined
return {
type: 'highlight', // token type (must match name)
raw: match[0], // full matched string: ==text==
text: match[1], // inner content: text
// Let the Markdown lexer process nested inline formatting
tokens: lexer.inlineTokens(match[1]),
}
},
},
// Parse Markdown token to Tiptap JSON
parseMarkdown: (token, helpers) => {
// Parse nested inline tokens into Tiptap inline content
const content = helpers.parseInline(token.tokens || [])
// Apply the 'highlight' mark to the parsed content
return helpers.applyMark('highlight', content)
},
addCommands() {
return {
toggleHighlight:
() =>
({ commands }) => {
return commands.toggleMark(this.name)
},
}
},
})
Notes:
helpers.parseInline
converts nested inline tokens into the tiptap-compatiblecontent
array.helpers.applyMark('highlight', content)
applies the mark to the parsed inline content and returns the structure the Markdown parser expects.
Step 4: Add the renderer
To support serializing editor state back to Markdown, implement the renderMarkdown
function. It receives a Tiptap node (or mark node structure) and should return the Markdown string. Use helpers.renderChildren
to serialize nested content.
import { Mark } from '@tiptap/core'
export const Highlight = Mark.create({
name: 'highlight',
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [{ tag: 'mark' }]
},
renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0]
},
// define a custom Markdown tokenizer to recognize ==text==
markdownTokenizer: {
name: 'highlight',
level: 'inline', // inline element
// fast hint for the lexer to find candidate positions
start: (src) => src.indexOf('=='),
tokenize: (src, tokens, lexer) => {
// Match ==text== at the start of the remaining source
const match = /^==([^=]+)==/.exec(src)
if (!match) return undefined
return {
type: 'highlight', // token type (must match name)
raw: match[0], // full matched string: ==text==
text: match[1], // inner content: text
// Let the Markdown lexer process nested inline formatting
tokens: lexer.inlineTokens(match[1]),
}
},
},
// Parse Markdown token to Tiptap JSON
parseMarkdown: (token, helpers) => {
// Parse nested inline tokens into Tiptap inline content
const content = helpers.parseInline(token.tokens || [])
// Apply the 'highlight' mark to the parsed content
return helpers.applyMark('highlight', content)
},
// Render Tiptap node back to Markdown
renderMarkdown: (node, helpers) => {
const content = helpers.renderChildren(node.content || [])
// Wrap serialized children in == delimiters
return `==${content}==`
},
addCommands() {
return {
toggleHighlight:
() =>
({ commands }) => {
return commands.toggleMark(this.name)
},
}
},
})
Notes:
helpers.renderChildren
serializes the node/mark children back to Markdown respecting nested formatting.- Ensure the
renderMarkdown
output matches the tokenizer's expectations (spacing/newlines). For inline marks like this the simple==content==
form is appropriate.
Usage
Add the extension to your editor and set content from Markdown:
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Markdown from 'some-markdown-integration' // replace with your Markdown integration
import { Highlight } from './highlight'
const editor = new Editor({
extensions: [StarterKit, Markdown, Highlight],
})
// Set Markdown content (the Markdown integration must support 'contentType' or similar option)
editor.commands.setContent('This is ==highlighted== text', { contentType: 'markdown' })
// Toggle highlight programmatically
editor.commands.toggleHighlight()
Testing and edge cases
- Nested inline formatting: The tokenizer used
lexer.inlineTokens(match[1])
so nested inline tokens (bold, emphasis, links) should be parsed and rendered correctly. - Greedy matches: The tokenizer uses a simple
^==([^=]+)==
regex — this will not match content containing==
inside. If you need to support nested==
or multiline highlights, extend the regex accordingly. - Whitespace handling: Some Markdown flavors permit spaces inside delimiters (e.g.,
== text ==
). If you want to support that, update the tokenizer and therenderMarkdown
output to preserve the chosen formatting. - Conflicts with other tokenizers: The
start
optimization helps the lexer pick candidate positions, but ensure your regex doesn't conflict with other inline tokenizers in your Markdown toolchain.