fix(editor): preserve prose markdown entities

This commit is contained in:
Neil
2026-09-18 01:56:06 -07:00
parent 45fcfef994
commit ba7e4dc85b
3 changed files with 137 additions and 0 deletions
@@ -39,6 +39,7 @@ import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-htm
import { RichMarkdownOrderedList } from './rich-markdown-ordered-list'
import { RichMarkdownCodeSpanPadding } from './rich-markdown-code-span-padding'
import { RichMarkdownListItem } from './rich-markdown-list-item'
import { RichMarkdownProseEntities } from './rich-markdown-prose-entities'
import { RichMarkdownParagraph } from './rich-markdown-paragraph'
import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
@@ -255,6 +256,7 @@ export function createRichMarkdownExtensions({
}
}),
RichMarkdownCodeSpanPadding,
RichMarkdownProseEntities,
createRichMarkdownAnnotationHighlightExtension()
]
@@ -0,0 +1,75 @@
import { Editor } from '@tiptap/core'
import { describe, expect, it } from 'vitest'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { encodeProseTextForMarkdown } from './rich-markdown-prose-entities'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
function roundTrip(source: string): string {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor(source, codec),
contentType: 'markdown'
})
try {
return editor.getMarkdown().trimEnd()
} finally {
editor.destroy()
}
}
describe('encodeProseTextForMarkdown', () => {
it.each([
['a < b && c'],
['Finance > Invoices'],
['F&B revenue'],
['Copyright &copy; 2026'],
['A &MadeUpEntity; here'],
['5 <6 and 7> 8']
])('leaves %j unescaped', (text) => {
expect(encodeProseTextForMarkdown(text)).toBe(text)
})
it.each([
['Press <kbd>x</kbd>', 'Press &lt;kbd>x&lt;/kbd>'],
['A <!-- comment --> here', 'A &lt;!-- comment --> here']
])('escapes tag openings in %j', (text, expected) => {
expect(encodeProseTextForMarkdown(text)).toBe(expected)
})
it('encodes a block-quote marker only in the first column', () => {
expect(encodeProseTextForMarkdown('> quoted')).toBe('&gt; quoted')
expect(encodeProseTextForMarkdown('Finance > Invoices')).toBe('Finance > Invoices')
})
})
describe('prose entity round trip', () => {
it.each([
['a < b && c'],
['Finance > Invoices'],
['F&B revenue'],
['Copyright &copy; 2026'],
['A &MadeUpEntity; here'],
['5 <6 and 7> 8'],
['Press <kbd>x</kbd> now.'],
['`a < b && c` stays literal']
])('preserves %j', (source) => {
expect(roundTrip(source)).toBe(source)
})
it('preserves prose entities inside a heading and a list item', () => {
const source = '# F&B > Revenue\n\n- a < b && c'
expect(roundTrip(source)).toBe(source)
})
it('is stable across three cycles', () => {
const source = 'Finance > Invoices, F&B, a < b && c, &copy; 2026'
let current = source
for (let cycle = 0; cycle < 3; cycle += 1) {
current = roundTrip(current)
}
expect(current).toBe(source)
})
})
@@ -0,0 +1,60 @@
import { Extension } from '@tiptap/core'
const TAG_OPENING = /^<(?:[a-zA-Z][a-zA-Z0-9-]*|\/[a-zA-Z][a-zA-Z0-9-]*|!|\?)/
type MarkdownTextEncoder = {
encodeTextForMarkdown?: (text: string, node: unknown, parentNode?: unknown) => string
}
function isMarkdownTextEncoder(value: unknown): value is MarkdownTextEncoder {
return typeof value === 'object' && value !== null
}
function escapeMarkdownSyntax(text: string): string {
return text.replace(/([\\`*_[\]~])/g, '\\$1')
}
/** Keep prose punctuation literal unless its raw form would change Markdown parsing. */
export function encodeProseTextForMarkdown(text: string): string {
let output = ''
let lineStart = true
for (let index = 0; index < text.length; index += 1) {
const character = text[index]
if (character === '<' && TAG_OPENING.test(text.slice(index))) {
output += '&lt;'
} else if (character === '>' && lineStart) {
output += '&gt;'
} else {
output += character
}
if (character === '\n') {
lineStart = true
} else if (lineStart && (character === ' ' || character === '\t')) {
lineStart = true
} else {
lineStart = false
}
}
return escapeMarkdownSyntax(output)
}
/** Restore prose entities after TipTap's serializer has classified code contexts. */
export const RichMarkdownProseEntities = Extension.create({
name: 'richMarkdownProseEntities',
priority: 1,
onBeforeCreate() {
const managerValue: unknown = this.editor.markdown
if (!isMarkdownTextEncoder(managerValue)) {
return
}
const base = managerValue.encodeTextForMarkdown
if (typeof base !== 'function') {
return
}
managerValue.encodeTextForMarkdown = (text, node, parentNode) => {
const encoded = base.call(managerValue, text, node, parentNode)
return encoded === text ? text : encodeProseTextForMarkdown(text)
}
}
})