使用支持 Markdown 的 Admonition 块

Beta

本指南将引导你在 Tiptap 中为自定义的 “Admonition” 块添加 Markdown 支持。我们将过程拆分为四个明确的步骤,并在每个步骤中包含完整的示例代码,以便你始终拥有完整上下文。

步骤

  1. 创建基础的 Tiptap Node 扩展(不含 Markdown 支持)。
  2. 添加自定义 Markdown 分词器,将原始 Markdown 转换为标记(tokens)。
  3. 添加解析器,将标记转换成 Tiptap 的 JSON 格式。
  4. 添加渲染器(序列化器),将 Tiptap 节点转换回 Markdown。

我们将采用 :::type 的风格定义 admonition,例如:

:::warning
这是带有 **加粗** 文本的警告。
:::

第 1 步:创建基础扩展

从一个最简的 Node 定义开始,该定义描述了结构、HTML 解析/渲染和属性。暂时不涉及 Markdown 集成,以便你专注于模式和 HTML 的输入输出。

import { Node } from '@tiptap/core'

export const Admonition = Node.create({
  name: 'admonition',

  group: 'block',
  content: 'block+',

  addAttributes() {
    return {
      type: {
        default: 'note',
        parseHTML: (element) => element.getAttribute('data-type'),
        renderHTML: (attributes) => ({
          'data-type': attributes.type,
        }),
      },
    }
  },

  parseHTML() {
    return [{ tag: 'div[data-admonition]' }]
  },

  renderHTML({ node, HTMLAttributes }) {
    return ['div', { 'data-admonition': '', ...HTMLAttributes }, 0]
  },
})

说明:

  • content: 'block+' 允许 admonition 内嵌套多个块内容。
  • admonition 的 type 存储为节点属性(HTML 中为 data-type)。

第 2 步:添加自定义 Markdown 分词器

Tiptap 的 Markdown 集成允许你添加一个分词器,将 Markdown 源码转换成 Markdown 解析器能理解的标记。分词器负责识别 :::type ... ::: 块,并返回一个包含相关元数据及嵌套标记(内容)的标记对象。

下面是一个完整示例,包含基础 Node 及添加的 markdownTokenizer,以便你了解分词器如何与 Node 集成。

import { Node } from '@tiptap/core'

export const Admonition = Node.create({
  name: 'admonition',

  group: 'block',
  content: 'block+',

  addAttributes() {
    return {
      type: {
        default: 'note',
        parseHTML: (element) => element.getAttribute('data-type'),
        renderHTML: (attributes) => ({
          'data-type': attributes.type,
        }),
      },
    }
  },

  parseHTML() {
    return [{ tag: 'div[data-admonition]' }]
  },

  renderHTML({ node, HTMLAttributes }) {
    return ['div', { 'data-admonition': '', ...HTMLAttributes }, 0]
  },

  markdownTokenizer: {
    name: 'admonition',
    level: 'block', // 块级元素

    // 快速起始检测:未找到时返回 -1。
    // 用于词法分析器优化扫描。
    start: (src) => src.indexOf(':::'),

    // 实际的分词函数,负责构建标记
    tokenize: (src, tokens, lexer) => {
      // 匹配格式:
      // :::type\n
      // (任意内容包括换行)\n
      // :::
      const match = /^:::(\w+)\n([\s\S]*?)\n:::\n?/.exec(src)
      if (!match) return undefined

      return {
        type: 'admonition',
        raw: match[0], // 完整匹配的 Markdown
        admonitionType: match[1], // 例如 'warning'
        text: match[2], // 内部 Markdown 内容

        // 让 Markdown 词法分析器将内部内容解析成块级标记。
        tokens: lexer.blockTokens(match[2]),
      }
    },
  },
})

实现细节:

  • start 用于词法分析器提前定位可能的起点。
  • markdownTokenizer.tokenize 不匹配时返回 undefined,否则必须返回包含 raw 字符串和解析时需要的字段的标记对象。
  • 使用 lexer.blockTokens()(或你的 Markdown 工具链中的类似方法)将内部内容解析成子块标记,方便解析器复用已有的块级解析逻辑。

第 3 步:添加解析器

parseMarkdown 函数接收分词器产生的标记,必须返回 Tiptap 兼容的节点 JSON。使用提供的 helpers 解析子标记为子内容。

以下是包含基础 Node、分词器及 parseMarkdown 函数的完整示例,展示了各部分如何协同工作。

import { Node } from '@tiptap/core'

export const Admonition = Node.create({
  name: 'admonition',

  group: 'block',
  content: 'block+',

  addAttributes() {
    return {
      type: {
        default: 'note',
        parseHTML: (element) => element.getAttribute('data-type'),
        renderHTML: (attributes) => ({
          'data-type': attributes.type,
        }),
      },
    }
  },

  parseHTML() {
    return [{ tag: 'div[data-admonition]' }]
  },

  renderHTML({ node, HTMLAttributes }) {
    return ['div', { 'data-admonition': '', ...HTMLAttributes }, 0]
  },

  markdownTokenizer: {
    name: 'admonition',
    level: 'block',

    start: (src) => src.indexOf(':::'),

    tokenize: (src, tokens, lexer) => {
      const match = /^:::(\w+)\n([\s\S]*?)\n:::\n?/.exec(src)
      if (!match) return undefined

      return {
        type: 'admonition',
        raw: match[0],
        admonitionType: match[1],
        text: match[2],
        tokens: lexer.blockTokens(match[2]),
      }
    },
  },

  // 将 Markdown 标记解析成 Tiptap JSON
  parseMarkdown: (token, helpers) => {
    return {
      type: 'admonition',
      attrs: { type: token.admonitionType || 'note' },
      // 使用 helpers 解析子标记为 Tiptap 内容
      content: helpers.parseChildren(token.tokens || []),
    }
  },
})

说明:

  • helpers.parseChildren 会将内部标记转换为 Tiptap 期望的节点内容数组。
  • 确保这里的 type 与你节点的 name 一致。

第 4 步:添加渲染器

为了将内容序列化回 Markdown,实现 renderMarkdown 函数。该函数接收一个 Tiptap 节点,应返回对应的 Markdown 字符串。使用 helpers.renderChildren 序列化节点内容。

下面是包含分词器、解析器和渲染器的完整示例,这样你就拥有了一个支持 Markdown 输入输出和 HTML 渲染的完整扩展。

import { Node } from '@tiptap/core'

export const Admonition = Node.create({
  name: 'admonition',
  group: 'block',
  content: 'block+',

  addAttributes() {
    return {
      type: {
        default: 'note',
        parseHTML: (element) => element.getAttribute('data-type'),
        renderHTML: (attributes) => ({
          'data-type': attributes.type,
        }),
      },
    }
  },

  parseHTML() {
    return [{ tag: 'div[data-admonition]' }]
  },

  renderHTML({ node, HTMLAttributes }) {
    return ['div', { 'data-admonition': '', ...HTMLAttributes }, 0]
  },

  markdownTokenizer: {
    name: 'admonition',
    level: 'block',

    start: (src) => src.indexOf(':::'),

    tokenize: (src, tokens, lexer) => {
      const match = /^:::(\w+)\n([\s\S]*?)\n:::\n?/.exec(src)
      if (!match) return undefined

      return {
        type: 'admonition',
        raw: match[0],
        admonitionType: match[1],
        text: match[2],
        tokens: lexer.blockTokens(match[2]),
      }
    },
  },

  parseMarkdown: (token, helpers) => {
    return {
      type: 'admonition',
      attrs: { type: token.admonitionType || 'note' },
      content: helpers.parseChildren(token.tokens || []),
    }
  },

  renderMarkdown: (node, helpers) => {
    const type = node.attrs?.type || 'note'
    const content = helpers.renderChildren(node.content || [])
    // 重新构造 :::type ... ::: 块。确保空格和换行符符合你的 Markdown 解析器要求。
    return `:::${type}\n${content}:::\n\n`
  },
})

使用方法

要从使用 admonition 语法的 Markdown 设置编辑器内容,传入 Markdown 字符串并确保设置了 contentType: 'markdown'(根据你的编辑器集成而定):

const markdown = `
:::warning
这是带有 **加粗** 文本的警告消息。
:::
`

editor.commands.setContent(markdown, { contentType: 'markdown' })

这将创建一个 admonition 节点,属性为 type: 'warning',嵌套内容将作为 Markdown 解析。


测试和边界情况

  • 嵌套块:分词器调用 lexer.blockTokens() 解析内部内容,内部 Markdown(列表、段落、标题等)会被解析为常规块标记并转换为 Tiptap 内容。
  • 行内格式:admonition 内的加粗、斜体、链接等格式应由你的 Markdown 解析器处理,前提是 helpers.renderChildrenhelpers.parseChildren 使用相同的标记集。
  • 尾部换行:注意分词器正则消耗的尾部换行符。可调整正则表达式或 renderMarkdown 输出以匹配你的 Markdown 工具链预期。
  • 类型校验:如需限制特定类型(例如 note | warning | tip | danger),可在 markdownTokenizer.tokenizeparseMarkdown 中验证 admonitionType,并在必要时回退为默认值。