Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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/fix-markdown-empty-doc-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiptap/markdown": patch
---

Fix `MarkdownManager.parse()` returning a document with empty `content` for markdown that yields no renderable blocks — whitespace-only input, or input whose only token has no registered handler (e.g. a leading-whitespace-indented line parsed as a code block when no code-block extension is present). A `doc` node requires at least one block child, so the empty document made `setContent` throw `RangeError: Invalid content for node doc: <>`. `parse()` now falls back to a single empty paragraph in that case, matching how an empty markdown string is represented.
3 changes: 2 additions & 1 deletion packages/extension-table/__tests__/tableMarkdown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Code } from '@tiptap/extension-code'
import Document from '@tiptap/extension-document'
import HardBreak from '@tiptap/extension-hard-break'
import Heading from '@tiptap/extension-heading'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
Expand Down Expand Up @@ -103,7 +104,7 @@
const markdown = '`a | b`\n---'
const parsed = manager.parse(markdown)
const block = parsed.content?.[0]
expect(block?.type).toBe('heading')

Check failure on line 107 in packages/extension-table/__tests__/tableMarkdown.spec.ts

View workflow job for this annotation

GitHub Actions / Run unit tests

packages/extension-table/__tests__/tableMarkdown.spec.ts > table markdown — inline code with pipe characters > should treat a setext heading with pipe in title as a heading, not a table

AssertionError: expected 'paragraph' to be 'heading' // Object.is equality Expected: "heading" Received: "paragraph" ❯ packages/extension-table/__tests__/tableMarkdown.spec.ts:107:25
// The pipe must be preserved verbatim inside the code span.
const codeNode = block?.content?.find((n: any) => n.marks?.some((m: any) => m.type === 'code'))
expect(codeNode?.text).toBe('a | b')
Expand Down Expand Up @@ -148,7 +149,7 @@

describe('table markdown alignment', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, TableKit],
extensions: [Document, Heading, Paragraph, Text, TableKit],
})

it('should parse and serialize left/right/center table alignment', () => {
Expand Down
142 changes: 142 additions & 0 deletions packages/markdown/__tests__/empty-doc-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Editor, Extension, Node } from '@tiptap/core'
import { Document } from '@tiptap/extension-document'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { Markdown, MarkdownManager } from '@tiptap/markdown'
import { afterEach, describe, expect, it } from 'vitest'

// A custom extension whose markdown handler returns a bare top-level text
// node instead of wrapping it in a block. Simulates a third-party extension
// misbehaving, to prove the doc-validity fallback doesn't just check
// `content.length > 0`.
const BareTextBlock = Extension.create({
name: 'bareTextBlock',
markdownTokenName: 'bareTextBlock',
parseMarkdown: token => ({ type: 'text', text: token.text || '' }),
markdownTokenizer: {
name: 'bareTextBlock',
level: 'block',
start: ':::bare',
tokenize: (src: string) => {
const match = src.match(/^:::bare\s+(.+?)\s+:::/)
if (!match) {
return undefined
}
return { type: 'bareTextBlock', raw: match[0], text: match[1] }
},
},
})

// A real block node, registered via `registerExtension()` after construction
// rather than passed to the constructor. Proves the block-node cache reads
// `this.extensions` (every extension ever registered) and not just
// `this.baseExtensions` (only the constructor's initial list).
const DirectlyRegisteredCallout = Node.create({
name: 'callout',
group: 'block',
content: 'text*',
markdownTokenName: 'callout',
parseMarkdown: (token, helpers) => ({
type: 'callout',
content: helpers.parseInline(token.tokens || []),
}),
markdownTokenizer: {
name: 'callout',
level: 'block',
start: ':::callout',
tokenize: (src: string, _tokens: unknown, lexer: any) => {
const match = src.match(/^:::callout\s+(.+?)\s+:::/)
if (!match) {
return undefined
}
return {
type: 'callout',
raw: match[0],
text: match[1],
tokens: lexer.inlineTokens(match[1]),
}
},
},
})

/**
* Regression tests for #7914.
*
* A `doc` node requires at least one block child, so markdown that yields no
* renderable blocks must not parse to a doc with empty content — that makes
* `setContent` throw `RangeError: Invalid content for node doc: <>`.
*/
describe('markdown parse never yields an empty document (#7914)', () => {
let editor: Editor | undefined
afterEach(() => editor?.destroy())

const mm = new MarkdownManager({ extensions: [Document, Paragraph, Text] })

it.each([[' / '], [' \\'], [' '], ['']])(
'parse(%j) returns a valid doc with at least one block',
input => {
const json = mm.parse(input)
expect(json.type).toBe('doc')
expect(json.content!.length).toBeGreaterThanOrEqual(1)
expect(json.content![0].type).toBe('paragraph')
},
)

it('setContent with whitespace+slash markdown does not throw', () => {
expect(() => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown],
content: ' / ',
contentType: 'markdown',
})
}).not.toThrow()
expect(editor!.getJSON().content![0].type).toBe('paragraph')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('editor.commands.setContent with whitespace+slash markdown does not throw', () => {
editor = new Editor({ extensions: [Document, Paragraph, Text, Markdown] })

expect(() => {
editor!.commands.setContent(' / ', { contentType: 'markdown' })
}).not.toThrow()
expect(editor!.getJSON().content![0].type).toBe('paragraph')
})

it('still parses meaningful single-character content', () => {
expect(mm.parse('/')).toMatchObject({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: '/' }] }],
})
})

it('falls back to a paragraph when a custom handler yields a bare top-level text node', () => {
const bareTextManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, BareTextBlock],
})
const json = bareTextManager.parse(':::bare hello :::')

expect(json.type).toBe('doc')
expect(json.content!.length).toBeGreaterThanOrEqual(1)
expect(json.content![0].type).toBe('paragraph')
})

it('setContent does not throw when a custom handler yields a bare top-level text node', () => {
expect(() => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown, BareTextBlock],
content: ':::bare hello :::',
contentType: 'markdown',
})
}).not.toThrow()
expect(editor!.getJSON().content![0].type).toBe('paragraph')
})

it('recognizes a block extension registered directly via registerExtension, not just the constructor', () => {
const directManager = new MarkdownManager({ extensions: [Document, Paragraph, Text] })
directManager.registerExtension(DirectlyRegisteredCallout)

const json = directManager.parse(':::callout hello :::')

expect(json.content![0].type).toBe('callout')
})
})
44 changes: 43 additions & 1 deletion packages/markdown/src/MarkdownManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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 node type names the registered schema considers block nodes. */
private blockNodeNamesCache: Set<string> | null = null

/**
* Create a MarkdownManager.
Expand Down Expand Up @@ -123,6 +125,7 @@ export class MarkdownManager {
registerExtension(extension: AnyExtension): void {
// Keep track of all extensions for HTML parsing
this.extensions.push(extension)
this.blockNodeNamesCache = null

// Track extensions that declare `code: true` so we can skip HTML entity
// encoding inside code contexts without hardcoding specific type names.
Expand Down Expand Up @@ -348,10 +351,17 @@ export class MarkdownManager {
// Convert tokens to Tiptap JSON
const content = this.parseTokens(tokens, true)

// A document requires at least one block child. A non-empty `content`
// array isn't sufficient proof of that: a custom token handler can
// return a bare inline node (e.g. `{ type: 'text' }`) at the top
// level, which would still violate the schema's `block+` requirement.
const blockNodeNames = this.getBlockNodeNames()
const hasBlockContent = content.some(node => node.type && blockNodeNames.has(node.type))

// Return a document node containing the parsed content

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not delete this comment

return {
type: 'doc',
content,
content: hasBlockContent ? content : [{ type: 'paragraph' }],
}
} finally {
this.activeParseLexer = previousParseLexer
Expand Down Expand Up @@ -1051,6 +1061,38 @@ export class MarkdownManager {
return tags
}

/**
* Collect node type names in the `block` group. The result is cached until
* the next registration.
*/
private getBlockNodeNames(): Set<string> {
if (this.blockNodeNamesCache) {
return this.blockNodeNamesCache
}

const names = new Set<string>()

for (const extension of this.extensions) {
if (extension.type !== 'node') {
continue
}

const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
}
const group = callOrReturn(getExtensionField(extension, 'group', context))

if (typeof group === 'string' && group.split(' ').includes('block')) {
names.add(extension.name)
}
}

this.blockNodeNamesCache = names
return names
}

/**
* Build a JSONContent that preserves the original HTML markup as literal
* text. Used when the HTML would otherwise be silently dropped during
Expand Down
Loading