Create a Highlight Mark with Markdown Support

Beta

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:

  1. Create the basic Tiptap Mark extension (without Markdown support).
  2. Add a custom Markdown tokenizer to produce tokens from the raw Markdown.
  3. Add a parser that converts those tokens into Tiptap JSON (applying the mark).
  4. 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-compatible content 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 the renderMarkdown 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.