diff --git a/.changeset/fix-markdown-empty-doc-fallback.md b/.changeset/fix-markdown-empty-doc-fallback.md new file mode 100644 index 0000000000..fa432ff2be --- /dev/null +++ b/.changeset/fix-markdown-empty-doc-fallback.md @@ -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. diff --git a/packages/extension-table/__tests__/tableMarkdown.spec.ts b/packages/extension-table/__tests__/tableMarkdown.spec.ts index cbd4634409..bb2ef59010 100644 --- a/packages/extension-table/__tests__/tableMarkdown.spec.ts +++ b/packages/extension-table/__tests__/tableMarkdown.spec.ts @@ -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' @@ -9,7 +10,7 @@ import { describe, expect, it } from 'vitest' describe('table markdown — inline code with pipe characters', () => { const manager = new MarkdownManager({ - extensions: [Document, Paragraph, Text, Code, TableKit], + extensions: [Document, Heading, Paragraph, Text, Code, TableKit], }) it('should parse `||` inside a code span as a single cell', () => { @@ -148,7 +149,7 @@ describe('table markdown — inline code with pipe characters', () => { 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', () => { diff --git a/packages/markdown/__tests__/empty-doc-fallback.spec.ts b/packages/markdown/__tests__/empty-doc-fallback.spec.ts new file mode 100644 index 0000000000..a2aa5f6c6f --- /dev/null +++ b/packages/markdown/__tests__/empty-doc-fallback.spec.ts @@ -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') + }) + + 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') + }) +}) diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index 5d6256f921..f2991b9fe9 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -58,6 +58,8 @@ export class MarkdownManager { private codeTypes: Set = new Set() /** Lazy cache of tag names declared by the registered schema's parseDOM rules. */ private schemaParseDomTagsCache: Set | null = null + /** Lazy cache of node type names the registered schema considers block nodes. */ + private blockNodeNamesCache: Set | null = null /** * Create a MarkdownManager. @@ -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. @@ -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 return { type: 'doc', - content, + content: hasBlockContent ? content : [{ type: 'paragraph' }], } } finally { this.activeParseLexer = previousParseLexer @@ -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 { + if (this.blockNodeNamesCache) { + return this.blockNodeNamesCache + } + + const names = new Set() + + 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