fix(editor): carry escapes as a mark so emphasis and links stay continuous

Second-round review of the atom approach: Tiptap's serializer closes every
active mark before a non-text inline node and reopens it after, so an
escape atom inside bold split the run (`**cost \$5 total**` came back as
`**cost **\$**5 total**`, which no longer parses as bold), a link containing
an escape became two links, and find/replace was disabled on any match
touching an escape because atoms are read-only.

The escape is now a non-inclusive mark on ordinary text. A mark's markdown
is one prefix for the whole run, so `getMarkdown` is wrapped to rewrite
marked text as `\X` per character before the manager serializes; inside a
code mark the character is emitted bare, and `& < >` are left to the
serializer's entity encoding. The mark is registered after `Markdown`
because that extension's onBeforeCreate installs the getMarkdown being
wrapped.

Also restores tiptap-marked-facade.ts to the base branch: the previous
commit meant to remove the escape-token rewrite there but restored the
file from the branch's own HEAD, leaving a dead override in the diff.
This commit is contained in:
averydev
2026-09-18 04:45:16 -07:00
committed by Neil
parent df6ccf2500
commit 7bc2bd16ce
4 changed files with 83 additions and 57 deletions
@@ -635,13 +635,37 @@ describe('rich markdown round trip', () => {
'\\*\\*not bold\\*\\* and \\_not em\\_',
'\\`not code\\` and \\~\\~not strike\\~\\~',
'C:\\\\Program Files\\\\(x86)',
'shell \\$HOME\\$ var'
'shell \\$HOME\\$ var',
// Why: marks must stay continuous around an escape; consecutive escapes need one backslash each.
'**cost \\$5 total** and **\\$5 fee** and **bold \\$**',
'[a \\$ b](http://x) and *a \\_ b* and *\\*literal\\**',
'**\\*\\*not bold\\*\\*** inside bold'
].join('\n\n')
const once = roundTripMarkdown(`${content}\n`)
expect(once).toBe(content)
expect(roundTripMarkdown(`${once}\n`)).toBe(content)
})
it('does not escape inside code marks and drops the backslash for entity-encoded characters', () => {
expect(roundTripMarkdown('a \\& b and \\< c\n')).toBe('a &amp; b and &lt; c')
// Why: a code span never contains an escape token, so an escaped mark can only reach code
// through editing; the serializer must then emit the literal character.
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions({ codec }),
content: encodeRawMarkdownHtmlForRichEditor('cost \\$5\n', codec),
contentType: 'markdown'
})
try {
editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size - 1 })
editor.commands.setCode()
expect(editor.getMarkdown()).toBe('`cost $5`')
} finally {
editor.destroy()
}
})
it('keeps escaped dollars inside table cells', () => {
expect(roundTripMarkdown('| Item | Amount |\n|---|---|\n| Fee | \\$500 |\n')).toContain(
'\\$500'
@@ -1,33 +1,27 @@
import { Node } from '@tiptap/core'
import { Mark, type JSONContent } from '@tiptap/core'
// marked's GFM inline escape rule: a backslash before ASCII punctuation.
const ESCAPABLE_CHARACTER_PATTERN = /[!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~]/
const ESCAPED_CHARACTER_PATTERN = /^\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/
const NODE_NAME = 'richMarkdownEscapedCharacter'
// Why: the serializer entity-encodes these itself; a backslash in front would survive as literal text.
const ENTITY_ENCODED_CHARACTERS = new Set(['&', '<', '>'])
const MARK_NAME = 'richMarkdownEscapedCharacter'
const MARKER_ATTRIBUTE = 'data-rich-markdown-escaped-character'
/**
* One inline atom per backslash-escaped character. Tiptap's markdown parser has
* no handler for marked's `escape` token (the character was deleted on load),
* and its text serializer never re-escapes, so a text node could not carry the
* backslash back to disk. An atom that owns the token round-trips `\$` as `\$`.
* Text that was backslash-escaped in the source. Tiptap's markdown parser has no
* handler for marked's `escape` token (the character was deleted on load) and its
* text serializer never re-escapes, so the mark carries the escape through the
* document and `getMarkdown` writes it back per character (see below).
*/
export const RichMarkdownEscapedCharacter = Node.create({
name: NODE_NAME,
inline: true,
group: 'inline',
atom: true,
// Why: reads as one character of text; a node selection on click would be noise.
selectable: false,
export const RichMarkdownEscapedCharacter = Mark.create({
name: MARK_NAME,
inclusive: false,
keepOnSplit: false,
addAttributes() {
return {
character: { default: '', rendered: false }
}
},
markdownTokenName: NODE_NAME,
markdownTokenName: MARK_NAME,
markdownTokenizer: {
name: NODE_NAME,
name: MARK_NAME,
level: 'inline',
start: (src: string) => src.indexOf('\\'),
tokenize(src: string) {
@@ -35,33 +29,59 @@ export const RichMarkdownEscapedCharacter = Node.create({
if (!match) {
return undefined
}
return { type: NODE_NAME, raw: match[0], character: match[1] }
return { type: MARK_NAME, raw: match[0], character: match[1] }
}
},
parseMarkdown: (token, helpers) => {
const character = (token as { character?: string }).character
if (token.type !== NODE_NAME || !character) {
if (token.type !== MARK_NAME || !character) {
return []
}
return helpers.createNode(NODE_NAME, { character })
return helpers.applyMark(MARK_NAME, [{ type: 'text', text: character }])
},
renderMarkdown: (node) => `\\${String(node.attrs?.character ?? '')}`,
renderText: ({ node }) => String(node.attrs.character ?? ''),
// Why: a mark's markdown is one prefix for the whole run, so `\*\*` would come out as `\**`;
// this is only the fallback for a serializer that bypasses getMarkdown.
renderMarkdown: (node, helpers) => `\\${helpers.renderChildren(node)}`,
parseHTML() {
return [
{
tag: `span[${MARKER_ATTRIBUTE}]`,
getAttrs: (element: HTMLElement) => {
const character = element.getAttribute(MARKER_ATTRIBUTE) ?? ''
return ESCAPED_CHARACTER_PATTERN.test(`\\${character}`) ? { character } : false
}
}
]
return [{ tag: `span[${MARKER_ATTRIBUTE}]` }]
},
renderHTML() {
return ['span', { [MARKER_ATTRIBUTE]: '' }, 0]
},
renderHTML({ node }) {
const character = String(node.attrs.character ?? '')
return ['span', { [MARKER_ATTRIBUTE]: character }, character]
onBeforeCreate() {
// Why: must be registered after `Markdown`, whose onBeforeCreate installs the
// getMarkdown this replaces; the manager itself is what serializes.
const editor = this.editor
editor.getMarkdown = () => {
const manager = editor.markdown
if (!manager) {
throw new Error('RichMarkdownEscapedCharacter requires the Markdown extension')
}
return manager.serialize(escapeMarkedCharacters(editor.getJSON()) as JSONContent)
}
}
})
/** Turns escaped-mark text back into `\X` per character so mark runs around it stay continuous. */
function escapeMarkedCharacters(node: JSONContent): JSONContent {
if (node.type === 'text' && node.marks?.some((mark) => mark.type === MARK_NAME)) {
const marks = node.marks.filter((mark) => mark.type !== MARK_NAME)
const insideCode = marks.some((mark) => mark.type === 'code')
const text = insideCode
? (node.text ?? '')
: Array.from(node.text ?? '')
.map((character) =>
ESCAPABLE_CHARACTER_PATTERN.test(character) && !ENTITY_ENCODED_CHARACTERS.has(character)
? `\\${character}`
: character
)
.join('')
return marks.length > 0 ? { ...node, text, marks } : { type: 'text', text }
}
if (!node.content) {
return node
}
return { ...node, content: node.content.map(escapeMarkedCharacters) }
}
@@ -249,7 +249,6 @@ export function createRichMarkdownExtensions({
throwOnError: false
}
}),
RichMarkdownEscapedCharacter,
createRichMarkdownLiteral(codec.transport),
...(htmlSuperscriptLinks
? [createRichMarkdownHtmlSuperscriptLink(codec.transport, htmlSuperscriptLinkContext!)]
@@ -13,17 +13,6 @@ import {
marked
} from 'marked'
/** Rewrites escape tokens in place (marked fills caller-owned arrays for table cells). */
function preserveEscapedCharacters(tokens: Token[]): Token[] {
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]
if (token.type === 'escape') {
tokens[index] = { type: 'text', raw: token.raw, text: token.text, escaped: false }
}
}
return tokens
}
export function createTiptapMarkedFacade(): typeof marked {
const registry = new Marked()
@@ -37,12 +26,6 @@ export function createTiptapMarkedFacade(): typeof marked {
extensions: registry.defaults.extensions
})
}
// Why: Tiptap's markdown parser has no case for marked's `escape` token, so
// `\$`, `\*`, `\_`, `\[` would be deleted from the document on load.
inlineTokens(src: string, tokens: Token[] = []): Token[] {
return preserveEscapedCharacters(super.inlineTokens(src, tokens))
}
}
const parser = (tokens: Token[], options?: MarkedOptions) => registry.parser(tokens, options)