自定义扩展中的 Markdown 集成
Beta
本指南将向您展示如何为您的 Tiptap 扩展添加 Markdown 支持。
提示:对于 Pandoc 块(
:::name)或短代码([name])等标准模式,请查看 实用函数,通过最少的代码生成 Markdown 规范。
基本扩展集成
要为扩展添加 Markdown 支持,请在扩展配置中定义一个 Markdown 配置:
import { Node } from '@tiptap/core'
const MyNode = Node.create({
name: 'myNode',
// ... 其他配置(parseHTML、renderHTML 等)
parseMarkdown: (token, helpers) => {
/* ... */
},
renderMarkdown: (node, helpers) => {
/* ... */
},
})Markdown 配置详解
扩展规范支持以下选项:
markdownTokenName- 需要处理的 token 名称。如果 token 名称与扩展名称不同,则必需。parseMarkdown- 将 token 转换为 Tiptap JSON 的函数。renderMarkdown- 将 Tiptap JSON 转换为 Markdown 字符串的函数。markdownTokenizer- 用于识别新的 Markdown 语法的自定义分词器。markdownOptions- 配置分词器的一组选项。indentsContent- 控制此节点是否增加嵌套内容的缩进级别(例如列表)。
使用 markdownTokenName
markdownTokenName: 'strong' // 针对 Markdown token 命名为 "strong" 的加粗标记扩展节点扩展
为节点创建 Markdown 支持非常简单。以下是一些常见模式。
根据您期望的内容类型,您可能需要在 parseMarkdown 和 renderMarkdown 方法中使用不同的辅助函数。
- 如果您的扩展是包含块级内容的节点,请使用
helpers.parseChildren和helpers.renderChildren。 - 如果您的扩展是包含块级内容的节点,且空行必须作为空段落往返传输,请使用
helpers.parseBlockChildren,以便保留隐式空段落。 - 如果您的扩展是包含行内级内容的节点,请使用
helpers.parseInline和helpers.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 || []) // 我们这里解析行内内容,因为标题包含行内内容
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}`
},
})标记扩展
标记的工作方式不同,因为它们包裹行内内容。要为您的标记扩展添加 Markdown 支持,使用 applyMark 和 renderChildren 辅助函数。
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) => {
// 解析内容并应用加粗标记
const content = helpers.parseInline(token.tokens || [])
return helpers.applyMark('bold', content)
},
renderMarkdown: (node, helpers) => {
const content = helpers.renderChildren(node.content || [])
return `**${content}**`
},
})如果您想给标记添加属性,请使用带属性对象的 applyMark 辅助函数。
const content = helpers.applyMark('link', helpers.parseInline(token.tokens || []), {
href: token.href || '',
title: token.title || null,
})测试您的扩展
单元测试解析处理函数
import { describe, it, expect } from 'vitest'
describe('Heading Markdown', () => {
it('should parse heading token', () => {
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' }],
})
})
})单元测试渲染处理函数
import { describe, it, expect } from 'vitest'
describe('Heading Markdown', () => {
it('should render heading node', () => {
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')
})
})集成测试
import { describe, it, expect } from 'vitest'
import { Editor } from '@tiptap/core'
import { Markdown } from '@tiptap/markdown'
import MyExtension from './MyExtension'
describe('MyExtension 集成', () => {
it('应该正确解析和序列化', () => {
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()
})
})常用模式
保留块内容中的空段落
如果您的节点包含其他块节点,且您希望连续的空段落在 Markdown 往返传输中得以保留,请优先使用 parseBlockChildren() 而非 parseChildren()。
parseMarkdown: (token, helpers) => {
return helpers.createNode('myContainer', undefined, helpers.parseBlockChildren(token.tokens || []))
}这将保留连续空段落中的第一个作为正常的 Markdown 空白间距,并将同一段落中后面的空段落保留为 标记。
在渲染期间使用上一个兄弟节点上下文
renderMarkdown() 接收一个包含 previousNode 的 context 对象,这使您能够做出感知兄弟节点的渲染决策,而无需硬编码父节点名称。
renderMarkdown: (node, helpers, context) => {
const previousNode = context.previousNode
const previousWasEmptyParagraph =
previousNode?.type === 'paragraph' && (!previousNode.content || previousNode.content.length === 0)
if (node.type === 'paragraph' && (!node.content || node.content.length === 0)) {
return previousWasEmptyParagraph ? ' ' : ''
}
return helpers.renderChildren(node.content || [])
}此模式在文档级别以及嵌套容器(如引用块或列表项)内部同样有效。
处理可选的 Token 属性
parse: (token, helpers) => {
return {
type: 'myNode',
attrs: {
level: token.depth || 1, // 缺失时的默认值
id: token.id ?? null, // 空值合并
text: token.text?.trim() || '', // 可选链
},
content: helpers.parseInline(token.tokens || []),
}
}条件解析
parse: (token, helpers) => {
// 只处理特定类型
if (token.ordered) {
return null // 让其他处理器处理
}
// 或根据 token 属性使用不同逻辑
if (token.depth > 6) {
// 改为作为段落处理
return {
type: 'paragraph',
content: helpers.parseInline(token.tokens || []),
}
}
return {
type: 'heading',
attrs: { level: token.depth },
content: helpers.parseInline(token.tokens || []),
}
}上下文感知渲染
render: (node, helpers, context) => {
const content = helpers.renderChildren(node.content || [])
// 根据上下文调整渲染
if (context.level > 0) {
// 嵌套 - 添加额外缩进
return helpers.indent(`- ${content}`) + '\n'
}
// 顶层
return `- ${content}\n`
}