Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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.
51 changes: 51 additions & 0 deletions packages/markdown/__tests__/conversion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,57 @@ 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')
})
})

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
30 changes: 27 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,22 @@ 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.
*
* Only relevant at the start of a line: block markers like `#`, `-`, `+` and
* ordered-list numbers are significant just after a line break. The caller
* passes `isLineStart` for the first inline child of a top-level paragraph.
*/
private escapeBlockSyntax(text: string): string {
return text
.replace(/^(#{1,6})(\s|$)/, '\\$1$2')
.replace(/^([-+])(\s|$)/, '\\$1$2')
.replace(/^(\d{1,9})([.)])(\s|$)/, '$1\\$2$3')
.replace(/^(-{3,})(\s*)$/, '\\$1$2')
}

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

if (node.type === 'text') {
let textContent = this.encodeTextForMarkdown(node.text || '', node, parentNode)
const isLineStart =
level === 0 && i === 0 && parentNode?.type === 'paragraph' && !node.marks?.length
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