-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Expand file tree
/
Copy pathempty-doc-fallback.spec.ts
More file actions
153 lines (136 loc) · 5.51 KB
/
Copy pathempty-doc-fallback.spec.ts
File metadata and controls
153 lines (136 loc) · 5.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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')
})
it('keeps a hardcoded token-type result (e.g. a setext heading) even without a matching registered extension', () => {
// parseToken's 'heading' case builds a { type: 'heading' } node directly —
// it doesn't depend on a Heading extension being registered. The fallback
// check must not mistake "no extension named 'heading'" for "not block
// content" and discard it.
const noHeadingManager = new MarkdownManager({ extensions: [Document, Paragraph, Text] })
const json = noHeadingManager.parse('Title\n---')
expect(json.content![0].type).toBe('heading')
})
})