mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 00:02:35 +00:00
fix(editor): preserve literal Markdown through Tiptap serialization
This commit is contained in:
@@ -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)',
|
||||
'',
|
||||
'[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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user