diff --git a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts
index 786274af072..2576bb31b21 100644
--- a/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts
+++ b/src/renderer/src/components/editor/isolated-markdown-extension-for-tests.ts
@@ -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 }
})
}
diff --git a/src/renderer/src/components/editor/rich-markdown-extension.test.ts b/src/renderer/src/components/editor/rich-markdown-extension.test.ts
index e1ebc9b4c99..d7a4a3646fb 100644
--- a/src/renderer/src/components/editor/rich-markdown-extension.test.ts
+++ b/src/renderer/src/components/editor/rich-markdown-extension.test.ts
@@ -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\]`,
+ ' [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()
}
- )
+ })
})
diff --git a/src/renderer/src/components/editor/rich-markdown-extension.ts b/src/renderer/src/components/editor/rich-markdown-extension.ts
index 2eb8940f560..c36c692b4b9 100644
--- a/src/renderer/src/components/editor/rich-markdown-extension.ts
+++ b/src/renderer/src/components/editor/rich-markdown-extension.ts
@@ -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)
+ }
+ })
+}
diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts
index 2dca4f05b57..9a4f292f042 100644
--- a/src/renderer/src/components/editor/rich-markdown-extensions.ts
+++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts
@@ -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
diff --git a/src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts b/src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts
new file mode 100644
index 00000000000..0589a6abe27
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.test.ts
@@ -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()
+ }
+ })
+})
diff --git a/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts
new file mode 100644
index 00000000000..d294bec3e06
--- /dev/null
+++ b/src/renderer/src/components/editor/rich-markdown-literal-serialization.ts
@@ -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()
+ let blocks: Map | 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
+ }
+ }
+}