From 9edad85d29166891ca9061598c049b882dbcc490 Mon Sep 17 00:00:00 2001 From: lazerg Date: Wed, 29 Jul 2026 09:09:51 +0500 Subject: [PATCH 1/2] fix(markdown): escape leading block syntax when serializing paragraphs --- .changeset/markdown-escape-block-syntax.md | 5 ++ .../markdown/__tests__/conversion.spec.ts | 51 +++++++++++++++++++ packages/markdown/src/MarkdownManager.ts | 30 +++++++++-- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 .changeset/markdown-escape-block-syntax.md diff --git a/.changeset/markdown-escape-block-syntax.md b/.changeset/markdown-escape-block-syntax.md new file mode 100644 index 0000000000..12532d47a3 --- /dev/null +++ b/.changeset/markdown-escape-block-syntax.md @@ -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. diff --git a/packages/markdown/__tests__/conversion.spec.ts b/packages/markdown/__tests__/conversion.spec.ts index 672f525d1a..a7c1b74ba3 100644 --- a/packages/markdown/__tests__/conversion.spec.ts +++ b/packages/markdown/__tests__/conversion.spec.ts @@ -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\\*`') diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index d6f31168c5..3406224a7d 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -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)) @@ -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 } /** @@ -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') + } + renderNodeToMarkdown( node: JSONContent, parentNode?: JSONContent, @@ -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 From d39dd4024be17c5ee01d47bc2857d0c8be15b375 Mon Sep 17 00:00:00 2001 From: lazerg Date: Wed, 29 Jul 2026 09:35:51 +0500 Subject: [PATCH 2/2] Escape block syntax on every line, not just the string start --- .../markdown/__tests__/conversion.spec.ts | 37 +++++++++++++++++++ packages/markdown/src/MarkdownManager.ts | 29 ++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/markdown/__tests__/conversion.spec.ts b/packages/markdown/__tests__/conversion.spec.ts index a7c1b74ba3..7fc855286d 100644 --- a/packages/markdown/__tests__/conversion.spec.ts +++ b/packages/markdown/__tests__/conversion.spec.ts @@ -814,6 +814,43 @@ describe('Markdown Conversion Tests', () => { 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', () => { diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index 3406224a7d..fba12773f5 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -1129,16 +1129,24 @@ export class MarkdownManager { * 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. + * 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 { - 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') + 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') } renderNodeToMarkdown( @@ -1256,7 +1264,10 @@ export class MarkdownManager { if (node.type === 'text') { const isLineStart = - level === 0 && i === 0 && parentNode?.type === 'paragraph' && !node.marks?.length + 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]))