自定义 Markdown 序列化

Beta

本指南将带你了解在 Tiptap 编辑器中实现自定义 Markdown 序列化的过程。完成本教程后,你将能够将 Tiptap JSON 序列化为 Markdown 内容。

将 Tiptap JSON 内容序列化为 Markdown 是通过 markdown.render 处理函数完成的。

理解 Render 处理函数

把 Tiptap JSON 节点转换为 Markdown 字符串的过程由扩展配置中定义的 renderMarkdown 函数处理。

该函数可以很简单,仅返回一个字符串,也可以非常复杂,考虑节点的属性、子节点、嵌套以及它出现的上下文。

renderMarkdown 函数接收以下参数:

  • node:要序列化的 Tiptap JSON 节点。
  • helpers:一个包含辅助渲染的实用函数对象(详情见渲染辅助函数)。
  • context:提供当前节点在文档树中位置等额外上下文信息的对象(详情见渲染上下文)。

渲染函数返回值应为一个字符串,代表节点对应的 Markdown 内容,该字符串将与其他字符串拼接形成完整的 Markdown 文档。

const CustomHeading = Node.create({
  name: 'customHeading',

  // ...

  renderMarkdown: (node, helpers) => {
    const content = helpers.renderChildren(node.content)
    return `# ${content}\n\n`
  },
})

渲染辅助函数

如上所述,helpers 对象提供了用于渲染子节点和格式化内容的辅助函数。 下面将逐一介绍它们的用途。

helpers.renderChildren(nodes, separator)

helpers.renderChildren 函数接收一个 Tiptap JSON 节点列表,并使用它们各自的渲染处理函数将其渲染为 Markdown 字符串。

可选的 separator 参数用于指定连接渲染后子节点的分隔符(默认是 '')。

render: (node, helpers) => {
  // 渲染所有子节点
  const content = helpers.renderChildren(node.content || [])

  return `> ${content}\n\n`
}

或者使用自定义分隔符:

render: (node, helpers) => {
  // 使用换行符连接列表项
  const items = helpers.renderChildren(node.content || [], '\n')

  return items + '\n\n'
}

helpers.indent(content)

helpers.indent(content) 函数会根据当前上下文级别和配置的缩进样式(空格或制表符)为传入内容字符串的每一行添加缩进。

当渲染嵌套结构(如列表)时非常有用。

render: (node, helpers) => {
  const content = helpers.renderChildren(node.content || [])

  return helpers.indent(content) // 根据当前上下文级别缩进渲染内容
}

helpers.wrapInBlock(prefix, content)

helpers.wrapInBlock 函数会在内容每一行前添加一个前缀,适用于块级元素如引用或代码块。

render: (node, helpers) => {
  const content = helpers.renderChildren(node.content || [])

  // 为每行添加 "> ",表示区块引用
  return helpers.wrapInBlock('> ', content)
}

序列化 Marks

Marks 不同于节点处理,因为它们包裹内联内容,需要应用于节点内部的文本。

渲染 Mark 时,通常先渲染节点的子内容,然后用该 Mark 的 Markdown 语法包裹起来。

const Bold = Mark.create({
  name: 'bold',

  renderMarkdown: (node, helpers) => {
    const content = helpers.renderChildren(node) // 直接渲染节点内容
    return `**${content}**` // 返回加粗的 Markdown 语法
  },
})

带属性的 Marks

const Link = Mark.create({
  name: 'link',

  // ...

  parseMarkdown: (token, helpers) => {
    const content = helpers.parseInline(token.tokens || [])

    // 使用第三个参数传入 Mark 的属性
    return helpers.applyMark('link', content, {
      href: token.href,
      title: token.title || null,
    })
  },

  renderMarkdown: (node, helpers) => {
    const content = helpers.renderChildren(node)
    const href = node.attrs?.href || ''
    const title = node.attrs?.title

    if (title) {
      return `[${content}](${href} "${title}")`
    }
    return `[${content}](${href})`
  },
})

渲染上下文

渲染处理函数的第三个参数是上下文对象,包含了当前节点在文档树中的位置信息,如索引、级别、父节点类型和自定义元数据。

renderMarkdown: (node, helpers, context) => {
  const lines = []

  lines.push(`我是父上下文中的第 ${context.index} 个子节点。`) // 节点在父节点中的零基索引
  lines.push(`我的当前嵌套级别是 ${context.level}`) // 当前嵌套级别(每遇到一个 indentsContent 为 true 的父节点则加 1)
  lines.push(`我的父节点类型是 ${context.parentType}`) // 父节点的类型
  lines.push(`我的自定义元数据是 ${JSON.stringify(context.meta)}`) // 父节点传递的自定义元数据

  return lines.join('\n')
}

带缩进的渲染

isIndenting 标记告诉 MarkdownManager 该节点会增加嵌套级别:

const CustomNode = Node.create({
  name: 'customNode',

  // ...

  markdownOptions: {
    indentsContent: true,
  },

  renderMarkdown: (node, helpers, context) => {
    // 如果使用 helpers.indent() 渲染子节点,会基于 context.level + 1 进行缩进
    const content = helpers.renderChildren(node.content || [])
    return content
  },
})

这对正确缩进嵌套列表和代码块很重要。

自定义缩进逻辑

你也可以实现自定义缩进:

render: (node, helpers, context) => {
  const content = helpers.renderChildren(node.content || [])

  // 根据级别添加自定义缩进
  const indent = '  '.repeat(context.level)
  const lines = content.split('\n')
  const indented = lines.map(line => indent + line).join('\n')

  return indented
}

调试序列化

对序列化前的 JSON 结构进行打印:

const json = editor.getJSON()
console.log(JSON.stringify(json, null, 2))

const markdown = editor.markdown.serialize(json)
console.log(markdown)

独立调试序列化

const node = {
  type: 'heading',
  attrs: { level: 1 },
  content: [{ type: 'text', text: 'Hello' }],
}

const renderHelpers = {
  renderChildren: nodes => 'Hello',
  // ... 其他辅助函数
}

const markdown = myExtension.options.markdown.render(node, renderHelpers, {})
console.log(markdown)

性能注意事项

缓存序列化结果

缓存 Markdown 序列化结果:

let markdownCache = null
let lastJSON = null

editor.on('update', () => {
  markdownCache = null // 使缓存失效
})

function getMarkdown() {
  const currentJSON = editor.getJSON()

  if (markdownCache && JSON.stringify(lastJSON) === JSON.stringify(currentJSON)) {
    return markdownCache
  }

  lastJSON = currentJSON
  markdownCache = editor.getMarkdown()
  return markdownCache
}

示例

自定义标题渲染器

const CustomHeading = Node.create({
  name: 'customHeading',

  renderMarkdown: (node, helpers, context) => {
    const level = node.attrs?.level || 1 // 从节点属性获取标题级别
    const prefix = '#'.repeat(level) // 根据级别生成对应的 # 前缀
    const content = helpers.renderChildren(node.content || []) // 渲染标题的内联内容

    return `${prefix} ${content}\n\n` // 根据已有信息构建 Markdown 字符串
  },
})

YouTube 嵌入渲染器

本例中,我们希望将 youtubeEmbed 节点序列化为能被自定义 YouTube 解析器识别的 Markdown 字符串。

语法形如: ![youtube](videoId?start=60&width=800&height=450)

const YouTubeEmbed = Node.create({
  name: 'youtubeEmbed',

  renderMarkdown: (node, helpers) => {
    // 从节点属性中提取信息
    const videoId = node.attrs?.videoId || ''
    const start = node.attrs?.start || 0
    const width = node.attrs?.width || 800
    const height = node.attrs?.height || 450

    // 拼接成 Markdown 字符串
    return `![youtube](${videoId}?start=${start}&width=${width}&height=${height})\n\n`
  },
})

带缩进的列表项渲染

本例中,我们希望渲染一个自定义列表节点,包含列表项,每个列表项语法形式为 => item

const CustomList = Node.create({
  name: 'customList',

  // ...

  markdownOptions: {
    indentsContent: true,
  },

  renderMarkdown: (node, helpers, context) => {
    // 使用自定义分隔符用换行符连接列表项
    const items = helpers.renderChildren(node.content || [], '\n')

    return items
  },
})

const CustomListItem = Node.create({
  name: 'customListItem',

  // ...

  renderMarkdown: (node, helpers, context) => {
    // 首先提取列表项的第一个子节点作为内容(段落节点)
    // 其余子节点用来之后手动渲染
    const [content, ...children] = node.content
    const output = [`=> ${content}`]

    // 先渲染列表项的直接内容
    const mainContent = helpers.renderChildren(node.content || [])

    // 再遍历其他子节点并带缩进渲染
    if (children && children.length > 0) {
      children.forEach((child) => {
        const childContent = helpers.renderChildren([child]) // 渲染子节点,同时递归渲染其子节点
        if (childContent) {
          // 拆分子内容的行并为每行添加缩进
          const indentedChild = childContent
            .split('\n')
            .map((line) => (line ? helpers.indent(line) : ''))
            .join('\n')
          output.push(indentedChild) // 添加带有正确缩进的子内容
        }
      })
    }

    return output.join('\n') // 用换行符连接所有行
  },
})

由于这非常繁琐,Tiptap 提供了一个 @tiptap/markdown 包中的 renderNestedMarkdownContent() 辅助函数,可以简化此操作:

import { Node } from '@tiptap/core'
import { renderNestedMarkdownContent } from '@tiptap/markdown'

const CustomListItem = Node.create({
  name: 'customListItem',

  // ...

  renderMarkdown: (node, helpers, context) => {
    return renderNestedMarkdownContent(node, helpers, '=> ', context)
  },
})

阅读更多内容请访问我们的工具函数页面。