Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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.
101 changes: 101 additions & 0 deletions packages/markdown/__tests__/empty-doc-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Editor, Extension } 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] }
},
},
})

/**
* 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')
})
})
58 changes: 57 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 schemaBlockNodeNamesCache: Set<string> | null = null

/**
* Create a MarkdownManager.
Expand Down Expand Up @@ -348,10 +350,16 @@ 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 hasBlockContent = content.some(node => this.getSchemaBlockNodeNames().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 +1059,54 @@ export class MarkdownManager {
return tags
}

/**
* Collect the node type names that are block nodes, so top-level parse
* output can be checked against the `doc` node's `block+` content
* requirement. Result is cached for the lifetime of the manager since
* extensions don't change after registration.
*
* Derived per-extension (mirroring ProseMirror's own `NodeType.isBlock`:
* not inline, not the top node, not `text`) rather than via `getSchema`,
* which requires every registered extension to combine into one globally
* valid ProseMirror schema. That's a stricter bar than markdown parsing
* needs — schema construction can fail for reasons unrelated to a given
* parse (duplicate/incompatible node names across an app's full extension
* set), and treating that failure as "no block nodes exist" would discard
* otherwise-correct parsed content.
*/
private getSchemaBlockNodeNames(): Set<string> {

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.

Shouldn't this function check for the group: "block" property of the Node extension?

if (this.schemaBlockNodeNamesCache) {
return this.schemaBlockNodeNamesCache
}

const names = new Set<string>()

flattenExtensions(this.baseExtensions).forEach(extension => {
if (extension.type !== 'node') {
return
}

// Match the context `extendNodeSchema`/field resolvers receive during
// real schema construction, since fields like `inline` can be defined
// as a function reading `this.options` (e.g. the Youtube extension).
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
}

const isInline = !!callOrReturn(getExtensionField(extension, 'inline', context))
const isTopNode = !!callOrReturn(getExtensionField(extension, 'topNode', context))

if (!isInline && !isTopNode && extension.name !== 'text') {
names.add(extension.name)
}
})

this.schemaBlockNodeNamesCache = names
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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