fix(editor): preserve literal Markdown through Tiptap serialization

This commit is contained in:
m4air
2026-09-14 16:10:43 -07:00
committed by m4air
parent aa0a1afd9d
commit 31c42547a8
6 changed files with 219 additions and 25 deletions
@@ -1,9 +1,10 @@
import { RichMarkdownExtension } from './rich-markdown-extension'
import { createRichMarkdownExtension } from './rich-markdown-extension'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
export function createIsolatedMarkdownExtensionForTests() {
return RichMarkdownExtension.configure({
marked: createRichMarkdownEditorCodec().marked,
const codec = createRichMarkdownEditorCodec()
return createRichMarkdownExtension(codec).configure({
marked: codec.marked,
markedOptions: { gfm: true }
})
}
@@ -24,32 +24,38 @@ describe('Markdown source compatibility without a DOM', () => {
}
)
it.each(['**literal**', '[literal](./example.md)'])(
'keeps typed formatting syntax %j literal after saving and reopening',
(text) => {
const editor = new Editor({
it.each([
'**literal**',
'[literal](./example.md)',
'![image](./image.png)',
'[literal][ref]',
'`[literal]`',
'[literal] _emphasis_',
String.raw`\[literal\]`,
'<script>alert(1)</script> [literal]'
])('keeps typed formatting syntax %j literal after saving and reopening', (text) => {
const editor = new Editor({
element: null,
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
content: '',
contentType: 'markdown'
})
try {
editor.commands.insertContentAt(1, { type: 'text', text })
const reopened = new Editor({
element: null,
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
content: '',
content: editor.getMarkdown(),
contentType: 'markdown'
})
try {
editor.commands.insertContentAt(1, { type: 'text', text })
const reopened = new Editor({
element: null,
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
content: editor.getMarkdown(),
contentType: 'markdown'
})
try {
expect(reopened.getText()).toBe(text)
expect(reopened.getJSON()).toEqual(editor.getJSON())
} finally {
reopened.destroy()
}
expect(reopened.getText()).toBe(text)
expect(reopened.getJSON()).toEqual(editor.getJSON())
} finally {
editor.destroy()
reopened.destroy()
}
} finally {
editor.destroy()
}
)
})
})
@@ -1,4 +1,6 @@
import { Markdown } from '@tiptap/markdown'
import { preserveLiteralMarkdownSource } from './rich-markdown-literal-serialization'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
export const RichMarkdownExtension = Markdown.extend({
onBeforeCreate(event) {
@@ -12,3 +14,15 @@ export const RichMarkdownExtension = Markdown.extend({
}
}
})
export function createRichMarkdownExtension(
codec: RichMarkdownEditorCodec,
htmlSuperscriptLinks = false
) {
return RichMarkdownExtension.extend({
onBeforeCreate(event) {
this.parent?.(event)
preserveLiteralMarkdownSource(this.editor, codec, htmlSuperscriptLinks)
}
})
}
@@ -10,7 +10,7 @@ import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { BlockMath, InlineMath } from '@tiptap/extension-mathematics'
import { RichMarkdownExtension } from './rich-markdown-extension'
import { createRichMarkdownExtension } from './rich-markdown-extension'
import { createLowlight, common } from 'lowlight'
import {
acquireLocalImageSrcLease,
@@ -243,7 +243,7 @@ export function createRichMarkdownExtensions({
createRawMarkdownHtmlBlock(codec.transport),
createMarkdownDocLink(codec.transport),
DragSelectionGuard,
RichMarkdownExtension.configure({
createRichMarkdownExtension(codec, htmlSuperscriptLinks).configure({
marked: codec.marked,
markedOptions: {
gfm: true
@@ -0,0 +1,101 @@
import { Editor, type JSONContent } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { describe, expect, it, vi } from 'vitest'
import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests'
function createEditor(content: JSONContent | string) {
return new Editor({
element: null,
extensions: [StarterKit, createIsolatedMarkdownExtensionForTests()],
content,
...(typeof content === 'string' ? { contentType: 'markdown' as const } : {})
})
}
function paragraph(text: string): JSONContent {
return { type: 'paragraph', content: [{ type: 'text', text }] }
}
function expectReopens(editor: Editor) {
const reopened = createEditor(editor.getMarkdown())
try {
expect(reopened.getJSON()).toEqual(editor.getJSON())
} finally {
reopened.destroy()
}
}
describe('literal Markdown serialization', () => {
it.each(['[ref]: ./target.md', '[ref]: <./target.md> "Title"'])(
'preserves literal references across blocks with definition %j',
(definition) => {
const editor = createEditor({
type: 'doc',
content: [paragraph('[literal][ref] and [ref]'), paragraph(definition)]
})
try {
expectReopens(editor)
} finally {
editor.destroy()
}
}
)
it('reuses unchanged blocks but revalidates edited syntax', () => {
const editor = createEditor({
type: 'doc',
content: [paragraph('[literal]'), paragraph('[[]]')]
})
const parse = vi.spyOn(editor.markdown!, 'parse')
try {
expect(editor.getMarkdown()).toBe('[literal]\n\n[[]]')
expect(parse).toHaveBeenCalledTimes(2)
editor.getMarkdown()
expect(parse).toHaveBeenCalledTimes(2)
editor.commands.insertContentAt(10, { type: 'text', text: '(./target.md)' })
const saved = editor.getMarkdown()
expect(parse).toHaveBeenCalledTimes(3)
expect(saved).toContain('\\[literal\\]')
expectReopens(editor)
} finally {
parse.mockRestore()
editor.destroy()
}
})
it('keeps upstream escaping when the parser cannot validate a block', () => {
const editor = createEditor('[[]]')
const upstream = editor.markdown!.serialize(editor.getJSON())
const parse = vi.spyOn(editor.markdown!, 'parse').mockImplementation(() => {
throw new Error('custom parser failure')
})
try {
expect(editor.getMarkdown()).toBe(upstream)
} finally {
parse.mockRestore()
editor.destroy()
}
})
it('preserves headings, marked text, and nested blocks', () => {
const editor = createEditor({
type: 'doc',
content: [
{ type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '[[]]' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: '[literal]', marks: [{ type: 'bold' }] }]
},
{
type: 'bulletList',
content: [{ type: 'listItem', content: [paragraph('[literal](./a)')] }]
}
]
})
try {
expectReopens(editor)
} finally {
editor.destroy()
}
})
})
@@ -0,0 +1,72 @@
import type { Editor, JSONContent } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport'
function withoutOptionalEscapes(markdown: string): string {
return markdown.replace(/\\([\\_[\]])/g, (escaped, character) =>
character === '\\' ? escaped : character
)
}
export function preserveLiteralMarkdownSource(
editor: Editor,
codec: RichMarkdownEditorCodec,
htmlSuperscriptLinks: boolean
): void {
const manager = editor.markdown!
const render = manager.renderNodeToMarkdown.bind(manager)
const serialize = editor.getMarkdown.bind(editor)
const cache = new WeakMap<ProseMirrorNode, { markdown: string; result: string }>()
let blocks: Map<JSONContent, ProseMirrorNode> | undefined
manager.renderNodeToMarkdown = (node, ...args) => {
const markdown = render(node, ...args)
const block = blocks?.get(node)
if (!block || !/\\[[\]]/.test(markdown)) {
return markdown
}
const cached = cache.get(block)
if (cached?.markdown === markdown) {
return cached.result
}
const candidate = withoutOptionalEscapes(markdown)
let result = markdown
try {
const parsed = manager.parse(
encodeRawMarkdownHtmlForRichEditor(candidate, codec, { htmlSuperscriptLinks })
)
// Every mark, attribute and text position must survive reopening this block.
if (parsed.content?.length === 1 && editor.schema.nodeFromJSON(parsed.content[0]).eq(block)) {
result = candidate
}
} catch {
// Keep the upstream escaped output when a custom parser cannot prove equivalence.
}
cache.set(block, { markdown, result })
return result
}
editor.getMarkdown = () => {
const markdown = serialize()
if (!/\\[[\]]/.test(markdown)) {
return markdown
}
// Reference definitions can change inline meaning across block boundaries.
if (/^ {0,3}\[[^\n]*\]:/m.test(withoutOptionalEscapes(markdown))) {
return markdown
}
const json = editor.getJSON()
blocks = new Map()
json.content?.forEach((node, index) => {
if (node.type === 'paragraph' || node.type === 'heading') {
blocks!.set(node, editor.state.doc.child(index))
}
})
try {
return manager.serialize(json)
} finally {
blocks = undefined
}
}
}