Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/markdown-escape-block-syntax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/markdown': patch
---

Escape block-level syntax like `\#` and `\-` at the start of a paragraph so escaped text survives a serialize/parse round-trip instead of turning into a heading or list.
88 changes: 88 additions & 0 deletions packages/markdown/__tests__/conversion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,94 @@ describe('Markdown Conversion Tests', () => {
})
})

describe('serializing: leading block syntax → backslash-escaped markdown', () => {
const paragraph = (text: string) => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
})

it('should escape a leading heading marker', () => {
expect(markdownManager.serialize(paragraph('# not a heading'))).toBe('\\# not a heading')
})

it('should escape a leading multi-level heading marker', () => {
expect(markdownManager.serialize(paragraph('## not a heading'))).toBe('\\## not a heading')
})

it('should escape a leading bullet-list marker', () => {
expect(markdownManager.serialize(paragraph('- not a list'))).toBe('\\- not a list')
expect(markdownManager.serialize(paragraph('+ not a list'))).toBe('\\+ not a list')
})

it('should escape a leading ordered-list marker', () => {
expect(markdownManager.serialize(paragraph('1. not a list'))).toBe('1\\. not a list')
expect(markdownManager.serialize(paragraph('1) not a list'))).toBe('1\\) not a list')
})

it('should escape a thematic-break line', () => {
expect(markdownManager.serialize(paragraph('---'))).toBe('\\---')
})

it('should not escape a block marker in the middle of a line', () => {
expect(markdownManager.serialize(paragraph('a # b'))).toBe('a # b')
})

it('should not escape a heading marker without a following space', () => {
expect(markdownManager.serialize(paragraph('#tag'))).toBe('#tag')
})

it('should escape a setext heading underline on a later line', () => {
expect(markdownManager.serialize(paragraph('Title\n---'))).toBe('Title\n\\---')
expect(markdownManager.serialize(paragraph('Title\n==='))).toBe('Title\n\\===')
})

it('should escape a two-dash underline (invalid as a thematic break, valid as setext)', () => {
expect(markdownManager.serialize(paragraph('Title\n--'))).toBe('Title\n\\--')
})

it('should escape a leading table-row pipe on a later line', () => {
expect(markdownManager.serialize(paragraph('a\n| b | c |'))).toBe('a\n\\| b | c |')
})

it('should escape a block marker indented up to three spaces', () => {
expect(markdownManager.serialize(paragraph(' # not a heading'))).toBe(
' \\# not a heading',
)
})

it('should escape a leading block marker after a hard break', () => {
const input = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Title' },
{ type: 'hardBreak' },
{ type: 'text', text: '# not a heading' },
],
},
],
}

expect(markdownManager.serialize(input)).toBe('Title \n\\# not a heading')
})
})

describe('round-trip: leading block syntax stays a paragraph', () => {
const roundTripType = (input: string) => {
const md = markdownManager.serialize(markdownManager.parse(input))
return markdownManager.parse(md).content[0].type
}

it('should keep escaped block syntax as a paragraph after round-trip', () => {
expect(roundTripType('\\# not a heading')).toBe('paragraph')
expect(roundTripType('1\\. not a list')).toBe('paragraph')
expect(roundTripType('\\- not a list')).toBe('paragraph')
expect(roundTripType('\\+ not a list')).toBe('paragraph')
})
})

describe('edge cases: code blocks and nested syntax', () => {
it('should NOT backslash-escape inside inline code marks (parsing)', () => {
const json = markdownManager.parse('`\\*not italic\\*`')
Expand Down
41 changes: 38 additions & 3 deletions packages/markdown/src/MarkdownManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,12 @@ export class MarkdownManager {
* Also backslash-escape markdown-significant characters in non-code text to
* prevent them from being misinterpreted as formatting delimiters.
*/
private encodeTextForMarkdown(text: string, node: JSONContent, parentNode?: JSONContent): string {
private encodeTextForMarkdown(
text: string,
node: JSONContent,
parentNode?: JSONContent,
isLineStart = false,
): string {
const isInsideCode =
(parentNode?.type != null && this.codeTypes.has(parentNode.type)) ||
(node.marks || []).some(m => this.codeTypes.has(typeof m === 'string' ? m : m.type))
Expand All @@ -1103,7 +1108,8 @@ export class MarkdownManager {
return text
}

return this.escapeMarkdownSyntax(encodeHtmlEntities(text))
const escaped = this.escapeMarkdownSyntax(encodeHtmlEntities(text))
return isLineStart ? this.escapeBlockSyntax(escaped) : escaped
}

/**
Expand All @@ -1119,6 +1125,30 @@ export class MarkdownManager {
return text.replace(/([\\`*_[\]~])/g, '\\$1')
}

/**
* Backslash-escape leading syntax that would otherwise start a block, so a
* text node keeps its meaning when the serialized markdown is parsed again.
*
* Relevant at the start of every line, not just the start of the whole
* string: a text node can contain literal `\n` characters (e.g. pasted or
* API-constructed content), and each of those lines is its own block-start
* context once serialized. Up to three leading spaces are still considered
* "line start" per CommonMark's block-indentation rule. The caller passes
* `isLineStart` for the first inline child of a top-level paragraph, and
* for the text run right after a hard break.
*/
private escapeBlockSyntax(text: string): string {
const escapeLine = (line: string): string =>
line
.replace(/^( {0,3})(#{1,6})(\s|$)/, '$1\\$2$3')
.replace(/^( {0,3})([-+])(\s|$)/, '$1\\$2$3')
.replace(/^( {0,3})(\d{1,9})([.)])(\s|$)/, '$1$2\\$3$4')
.replace(/^( {0,3})(-{2,}|={1,})(\s*)$/, '$1\\$2$3')
.replace(/^( {0,3})(\|)/, '$1\\$2')

return text.split('\n').map(escapeLine).join('\n')
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
renderNodeToMarkdown(
node: JSONContent,
parentNode?: JSONContent,
Expand Down Expand Up @@ -1233,7 +1263,12 @@ export class MarkdownManager {
}

if (node.type === 'text') {
let textContent = this.encodeTextForMarkdown(node.text || '', node, parentNode)
const isLineStart =
level === 0 &&
parentNode?.type === 'paragraph' &&
!node.marks?.length &&
(i === 0 || nodes[i - 1]?.type === 'hardBreak')
let textContent = this.encodeTextForMarkdown(node.text || '', node, parentNode, isLineStart)
const currentMarks = new Map((node.marks || []).map(mark => [mark.type, mark]))

// Find marks that need to be closed and opened
Expand Down