Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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/2026-08-07-markdown-unclosed-inline-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/markdown': patch
---

Markdown with inline HTML such as an unclosed `<b>` tag no longer parses into an invalid document. The tag is dropped and its text is kept.
31 changes: 31 additions & 0 deletions packages/markdown/__tests__/mixed-html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,35 @@ describe('MarkdownManager Mixed Markdown + HTML', () => {
expect(hasItalic).toBe(true)
})
})

it('parses an unclosed inline HTML tag without nesting a paragraph', () => {
const md = '<b>123'
const doc = manager.parse(md)

expect(doc.content).toEqual([{ type: 'paragraph', content: [{ type: 'text', text: '123' }] }])
})

it('parses an unclosed inline HTML tag and keeps the surrounding text', () => {
const md = 'a <b> b'
const doc = manager.parse(md)

// The dropped tag leaves both of the spaces around it.
expect(doc.content).toEqual([{ type: 'paragraph', content: [{ type: 'text', text: 'a b' }] }])
})

it('parses an empty inline HTML element without nesting a paragraph', () => {
const md = 'a <span></span> b'
const doc = manager.parse(md)

expect(doc.content).toEqual([{ type: 'paragraph', content: [{ type: 'text', text: 'a b' }] }])
})

it('parses inline HTML for a block element as its inline content', () => {
const md = 'a <h1>title</h1> b'
const doc = manager.parse(md)

expect(doc.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: 'a title b' }] },
])
})
})
67 changes: 57 additions & 10 deletions packages/markdown/src/MarkdownManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export class MarkdownManager {
private codeTypes: Set<string> = new Set()
/** Lazy cache of tag names declared by the registered schema's parseDOM rules. */
private schemaParseDomTagsCache: Set<string> | null = null
/** Lazy cache of the names of the schema's inline node types. */
private inlineNodeTypesCache: Set<string> | null = null
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Create a MarkdownManager.
Expand Down Expand Up @@ -971,17 +973,9 @@ export class MarkdownManager {
return parsed.content
}

// For inline HTML, we need to flatten the content appropriately
// If there's only one paragraph with content, unwrap it
if (
parsed.content.length === 1 &&
parsed.content[0].type === 'paragraph' &&
parsed.content[0].content
) {
return parsed.content[0].content
}
const inlineContent = this.toInlineContent(parsed.content)

return parsed.content
return inlineContent.length > 0 ? inlineContent : null
}

return parsed as JSONContent
Expand All @@ -990,6 +984,59 @@ export class MarkdownManager {
}
}

/**
* Keep only the inline nodes of parsed HTML content, unwrapping the block
* nodes around them. Inline HTML sits inside a textblock, where a block node
* would make the document invalid for the schema.
*
* @param content Content array of a parsed HTML fragment.
* @example
* toInlineContent([{ type: 'paragraph', content: [{ type: 'text', text: 'hi' }] }])
* // → [{ type: 'text', text: 'hi' }]
*/
private toInlineContent(content: JSONContent[]): JSONContent[] {
const inlineTypes = this.getInlineNodeTypes()

return content.flatMap(node => {
if (node.type && inlineTypes.has(node.type)) {
return [node]
}

return node.content ? this.toInlineContent(node.content) : []
})
}

/**
* Collect the names of the node types the schema treats as inline. Result is
* cached for the lifetime of the manager since extensions don't change after
* registration.
*
* @example
* getInlineNodeTypes().has('text') // → true
*/
private getInlineNodeTypes(): Set<string> {
if (this.inlineNodeTypesCache) {
return this.inlineNodeTypesCache
}

const types = new Set<string>(['text'])

try {
const schema = getSchema(this.baseExtensions)

Object.values(schema.nodes).forEach(type => {
if (type.isInline) {
types.add(type.name)
}
})
} catch {
// If schema construction fails, only text nodes count as inline.
}

this.inlineNodeTypesCache = types
return types
}

/**
* Returns true when the HTML contains a tag that is neither a standard
* HTML/SVG element nor declared in a registered extension's parseDOM rules.
Expand Down