snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('Blank lines after a block token', () => {
let markdownManager: MarkdownManager
beforeEach(() => {
markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
Heading,
Blockquote,
BulletList,
OrderedList,
ListItem,
Table,
TableRow,
TableHeader,
TableCell,
],
})
})
// Heading tokenizer absorbs trailing blank lines into the token's raw,
// so we recover them here to prevent losing a blank line per parse cycle.
it('preserves a blank line between a heading and the following text', () => {
const doc = markdownManager.parse('# Title\n\n\n\nBody')
expect(doc.content).toEqual([
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] },
])
})
it('does not create an empty paragraph when a heading is directly followed by text', () => {
const doc = markdownManager.parse('# Title\n\nBody')
expect(doc.content).toEqual([
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Title' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Body' }] },
])
})
it('round-trips blank lines after a heading without eroding them', () => {
const markdown = '# Title\n\n\n\nBody'
const once = markdownManager.serialize(markdownManager.parse(markdown))
const twice = markdownManager.serialize(markdownManager.parse(once))
expect(twice).toBe(once)
})
it('normalizes an explicit   paragraph without eroding the blank line', () => {
const once = markdownManager.serialize(markdownManager.parse('# Title\n\n \n\nBody'))
const twice = markdownManager.serialize(markdownManager.parse(once))
expect(once).toBe('# Title\n\n\n\nBody')
expect(twice).toBe(once)
})
// Tables absorb trailing blank lines the same way.
it('preserves a blank line between a table and the following text', () => {
const doc = markdownManager.parse('| a | b |\n| --- | --- |\n| 1 | 2 |\n\n\n\nAfter')
const kinds = (doc.content ?? []).map(node => node.type)
expect(kinds).toEqual(['table', 'paragraph', 'paragraph'])
expect(doc.content?.[1]).toEqual({ type: 'paragraph', content: [] })
})
// HTML blocks absorb trailing blank lines the same way.
it('preserves a blank line after a raw HTML block (parsed as paragraph)', () => {
const doc = markdownManager.parse('<div>test</div>\n\n\n\nAfter')
const kinds = (doc.content ?? []).map(node => node.type)
expect(kinds).toEqual(['paragraph', 'paragraph', 'paragraph'])
expect(doc.content?.[1]).toEqual({ type: 'paragraph', content: [] })
})
})

View File

@@ -0,0 +1,94 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('Blockquote Markdown Conversion', () => {
let markdownManager: MarkdownManager
beforeEach(() => {
markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, Blockquote, Heading],
})
})
it('should convert a single-level blockquote to markdown', () => {
const markdown = '> This is a blockquote'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should convert a single-level blockquote with multiple paragraphs to markdown', () => {
const markdown = '> First paragraph\n>\n> Second paragraph'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should convert nested blockquotes to markdown', () => {
const markdown = '> Level 1\n>\n> > Level 2\n> >\n> > > Level 3'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes with varying nesting depths', () => {
const markdown =
'> Level 1\n>\n> > Level 2\n> >\n> > > Level 3\n> >\n> > Back to level 2\n>\n> Back to level 1'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should convert nested blockquotes with multiple paragraphs', () => {
const markdown =
'> First paragraph\n>\n> Second paragraph\n>\n> > Nested paragraph\n> >\n> > > Deeply nested'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle a blockquote that jumps multiple levels deep', () => {
const markdown = '> Level 1\n>\n> > > Level 3 (jumped from 1 to 3)'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes going back to shallower nesting', () => {
const markdown =
'> Level 1\n>\n> > Level 2\n> >\n> > > Level 3\n> >\n> > Back to level 2\n>\n> Back to level 1'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes with text content at different levels', () => {
const markdown =
'> Outer quote with some text\n>\n> > Inner quote with more text\n> >\n> > > Innermost quote'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
it('should handle blockquotes with mixed content including headings', () => {
const markdown =
'> This is a block quote\n>\n> With more paragraphs\n>\n> # And a heading\n>\n> > And nested\n> >\n> > ## And another heading'
const json = markdownManager.parse(markdown)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe(markdown)
})
})

View File

@@ -0,0 +1,831 @@
import type { Extension } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Code } from '@tiptap/extension-code'
import { CodeBlock } from '@tiptap/extension-code-block'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { Italic } from '@tiptap/extension-italic'
import { Link } from '@tiptap/extension-link'
import { BulletList, ListItem, OrderedList, TaskItem, TaskList } from '@tiptap/extension-list'
import { Mention } from '@tiptap/extension-mention'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { Youtube } from '@tiptap/extension-youtube'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
import * as conversionfiles from '../fixtures/markdown/index.js'
describe('Markdown Conversion Tests', () => {
const extensions = [
Document,
Paragraph,
Text,
Bold,
Italic,
Code,
Link,
Heading,
HardBreak,
CodeBlock,
BulletList,
OrderedList,
ListItem,
TaskList,
TaskItem.configure({
nested: true,
}),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: undefined,
}),
Youtube.configure({
HTMLAttributes: {
class: 'youtube-video',
},
}),
]
const conversionExtensions: Extension[] = []
Object.values(conversionfiles).forEach((file: any) => {
if (!file?.extensions) {
return
}
conversionExtensions.push(...file.extensions)
})
const markdownManager = new MarkdownManager({
extensions: [...extensions, ...conversionExtensions],
})
describe('convert simple taskList from and to markdown', () => {
const simpleMarkdown = `
- [ ] Task 1
- [x] Task 2
- [ ] Task 3
`.trim()
const simpleJSON = {
type: 'doc',
content: [
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 1' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: true },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 2' }],
},
],
},
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task 3' }],
},
],
},
],
},
],
}
it('should convert simple markdown to expected JSON structure', () => {
const json = markdownManager.parse(simpleMarkdown)
expect(json).toEqual(simpleJSON)
})
it('should convert simple JSON structure back to expected markdown', () => {
const md = markdownManager.serialize(simpleJSON)
expect(md.trim()).toBe(simpleMarkdown.trim())
})
})
describe('convert taskList with leading paragraph from and to markdown', () => {
const markdownWithLeadingParagraph = `Leading paragraph
- [ ] Task`
const expectedJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Leading paragraph' }],
},
{
type: 'taskList',
content: [
{
type: 'taskItem',
attrs: { checked: false },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Task' }],
},
],
},
],
},
],
}
it('should convert markdown with leading paragraph to expected JSON structure', () => {
const json = markdownManager.parse(markdownWithLeadingParagraph)
expect(json).to.deep.equal(expectedJSON)
})
it('should convert JSON structure back to expected markdown', () => {
const md = markdownManager.serialize(expectedJSON)
expect(md).to.equal(markdownWithLeadingParagraph)
})
})
Object.values(conversionfiles).forEach(file => {
describe(`convert ${file.name} from and to markdown`, () => {
it(`should convert ${file.name} markdown to expected JSON structure`, () => {
const json = markdownManager.parse(file.expectedInput)
const normalizedActual = JSON.parse(JSON.stringify(json))
const normalizedExpected = JSON.parse(JSON.stringify(file.expectedOutput))
expect(normalizedActual).toEqual(normalizedExpected)
})
it(`should convert ${file.name} JSON structure back to expected markdown`, () => {
const md = markdownManager.serialize(file.expectedOutput)
expect(md.trim()).toBe(file.expectedInput.trim())
})
})
})
describe('multiple empty paragraphs', () => {
// The first top-level empty paragraph stays empty markdown spacing.
// Additional consecutive empty paragraphs are preserved with &nbsp; markers.
it('should preserve two empty paragraphs with blank spacing plus one &nbsp; marker when converting to markdown', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line1' }],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line2' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('Line1\n\n\n\n&nbsp;\n\nLine2')
})
it('should parse markdown with one preserved blank paragraph back correctly', () => {
const markdown = 'Line1\n\n&nbsp;\n\nLine2'
const json = markdownManager.parse(markdown)
const content = json.content!
expect(content).toHaveLength(3)
expect(content[0].type).toBe('paragraph')
expect(content[0].content![0].text).toBe('Line1')
expect(content[1].type).toBe('paragraph')
expect(content[1].content).toEqual([])
expect(content[2].type).toBe('paragraph')
expect(content[2].content![0].text).toBe('Line2')
})
it('should reserialize parsed empty paragraphs using blank spacing plus preserved markers', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line1' }],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line2' }],
},
],
}
const markdown = markdownManager.serialize(json)
const parsed = markdownManager.parse(markdown)
const content = parsed.content!
expect(content).toHaveLength(4)
expect(content[1].content).toEqual([])
expect(content[2].content).toEqual([])
const remarked = markdownManager.serialize(parsed)
expect(remarked).toBe(markdown)
})
it('should roundtrip five consecutive empty paragraphs without losing one', () => {
const json = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Line1' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Line2' }] },
],
}
const markdown = markdownManager.serialize(json)
const parsed = markdownManager.parse(markdown)
const content = parsed.content!
expect(markdown).toBe('Line1\n\n\n\n&nbsp;\n\n&nbsp;\n\n&nbsp;\n\n&nbsp;\n\nLine2')
expect(content).toHaveLength(7)
expect(
content.slice(1, 6).every(node => node.type === 'paragraph' && node.content?.length === 0),
).toBe(true)
})
it('should handle empty paragraphs without content field (real editor output)', () => {
// Real editor output may omit the content field for empty paragraphs
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line1' }],
},
{
type: 'paragraph',
// No content field - this is what the real editor might output
},
{
type: 'paragraph',
// No content field
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Line2' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('Line1\n\n\n\n&nbsp;\n\nLine2')
})
it('should parse literal NBSP character (\\u00A0) as empty paragraph', () => {
// Some markdown parsers may convert &nbsp; entity to the literal NBSP character
const markdown = 'Line1\n\n\u00A0\n\nLine2'
const json = markdownManager.parse(markdown)
const content = json.content!
expect(content).toHaveLength(3)
expect(content[0].type).toBe('paragraph')
expect(content[0].content![0].text).toBe('Line1')
expect(content[1].type).toBe('paragraph')
expect(content[1].content).toEqual([])
expect(content[2].type).toBe('paragraph')
expect(content[2].content![0].text).toBe('Line2')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('Line1\n\n\n\nLine2')
})
})
describe('tilde fenced code blocks', () => {
const codeBlockJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: 'code block' }],
},
],
}
const codeBlockWithLangJSON = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: 'js' },
content: [{ type: 'text', text: 'console.log("hello")' }],
},
],
}
it('should parse tilde fenced code blocks', () => {
const markdown = '~~~\ncode block\n~~~'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockJSON)
})
it('should parse tilde fenced code blocks with language', () => {
const markdown = '~~~js\nconsole.log("hello")\n~~~'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockWithLangJSON)
})
it('should parse backtick fenced code blocks', () => {
const markdown = '```\ncode block\n```'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockJSON)
})
it('should parse backtick fenced code blocks with language', () => {
const markdown = '```js\nconsole.log("hello")\n```'
const json = markdownManager.parse(markdown)
expect(json).toEqual(codeBlockWithLangJSON)
})
it('should produce the same result for tilde and backtick fenced code blocks', () => {
const tildeMarkdown = '~~~\ncode block\n~~~'
const backtickMarkdown = '```\ncode block\n```'
const tildeJSON = markdownManager.parse(tildeMarkdown)
const backtickJSON = markdownManager.parse(backtickMarkdown)
expect(tildeJSON).toEqual(backtickJSON)
})
})
describe('HTML character escaping', () => {
it('should decode &lt; and &gt; entities to literal < and > when parsing', () => {
const markdown = 'foo &lt;bar&gt; baz'
const json = markdownManager.parse(markdown)
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toHaveLength(1)
expect(json.content[0].content[0].text).toBe('foo <bar> baz')
})
it('should decode &amp; entity to literal & when parsing', () => {
const markdown = 'foo &amp; bar'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo & bar')
})
it('should encode < and > back to entities when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'foo <bar> baz' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('foo &lt;bar&gt; baz')
})
it('should encode & back to &amp; when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'foo & bar' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('foo &amp; bar')
})
it('should roundtrip &lt;bar&gt; correctly', () => {
const markdown = 'foo &lt;bar&gt; baz'
const json = markdownManager.parse(markdown)
// Editor should show literal <bar>
expect(json.content[0].content[0].text).toBe('foo <bar> baz')
// Serialize back should produce the entity form
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo &lt;bar&gt; baz')
})
it('should roundtrip &amp; correctly', () => {
const markdown = 'foo &amp; bar'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo & bar')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo &amp; bar')
})
it('should decode &quot; entity to literal " when parsing', () => {
const markdown = 'foo &quot;bar&quot; baz'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo "bar" baz')
})
it('should not encode " when serializing (quotes are valid markdown)', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'foo "bar" baz' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('foo "bar" baz')
})
it('should decode &quot; when parsing but serialize as literal "', () => {
const markdown = 'foo &quot;bar&quot; baz'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo "bar" baz')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo "bar" baz')
})
it('should not encode entities inside code blocks', () => {
const json = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: 'foo <bar> & baz' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('```\nfoo <bar> & baz\n```')
})
it('should not encode entities inside inline code marks', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '<tag>',
marks: [{ type: 'code' }],
},
],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('`<tag>`')
})
it('should handle doubly-encoded entities correctly', () => {
// &amp;lt; should decode to &lt; (not to <)
const markdown = 'foo &amp;lt; bar'
const json = markdownManager.parse(markdown)
expect(json.content[0].content[0].text).toBe('foo &lt; bar')
// Serializing should re-encode the & in &lt;
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('foo &amp;lt; bar')
})
it('should preserve &nbsp; empty paragraph behavior', () => {
const markdown = 'Line1\n\n&nbsp;\n\nLine2'
const json = markdownManager.parse(markdown)
// Empty paragraph check should still work
expect(json.content).toHaveLength(3)
expect(json.content[1].type).toBe('paragraph')
expect(json.content[1].content).toEqual([])
// A single empty paragraph between content paragraphs uses blank-line
// spacing (the first empty paragraph doesn't need an &nbsp; marker).
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('Line1\n\n\n\nLine2')
})
it('should roundtrip literal &amp;nbsp; without it being treated as an empty paragraph marker', () => {
// A user writing &amp;nbsp; in markdown intends for the text "&nbsp;" to display.
// decodeHtmlEntities decodes &amp; → &, producing text content "&nbsp;".
// On serialization, encodeHtmlEntities re-encodes & → &amp;, restoring &amp;nbsp;.
// The intermediate "&nbsp;" text must NOT be confused with the empty-paragraph marker.
const markdown = 'before &amp;nbsp; after'
const json = markdownManager.parse(markdown)
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content[0].text).toBe('before &nbsp; after')
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('before &amp;nbsp; after')
})
it('should preserve a standalone literal &amp;nbsp; paragraph as text', () => {
const markdown = '&amp;nbsp;'
const json = markdownManager.parse(markdown)
const content = json.content!
expect(content).toHaveLength(1)
expect(content[0].type).toBe('paragraph')
expect(content[0].content).toEqual([{ type: 'text', text: '&nbsp;' }])
const serialized = markdownManager.serialize(json)
expect(serialized).toBe('&amp;nbsp;')
})
})
describe('markdown escape character handling', () => {
describe('parsing: escape tokens in markdown → text nodes', () => {
it('should parse a backslash-escaped asterisk as a literal asterisk', () => {
const json = markdownManager.parse('\\*text\\*')
expect(json.content).toHaveLength(1)
expect(json.content[0].type).toBe('paragraph')
expect(json.content[0].content).toHaveLength(1)
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '*text*' })
})
it('should parse a backslash-escaped underscore as a literal underscore', () => {
const json = markdownManager.parse('\\_text\\_')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '_text_' })
})
it('should parse a backslash-escaped backtick as a literal backtick', () => {
const json = markdownManager.parse('\\`')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '`' })
})
it('should parse a double-backslash as a literal backslash', () => {
const json = markdownManager.parse('\\\\')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '\\' })
})
it('should parse escaped double-asterisks as literal double-asterisks', () => {
const json = markdownManager.parse('\\*\\*bold\\*\\*')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '**bold**' })
})
it('should parse a backslash-escaped tilde as a literal tilde', () => {
const json = markdownManager.parse('\\~text\\~')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '~text~' })
})
it('should parse escaped brackets as literal brackets', () => {
const json = markdownManager.parse('\\[link\\]')
expect(json.content[0].content[0]).toEqual({ type: 'text', text: '[link]' })
})
})
describe('parsing: mixed escape tokens and real formatting', () => {
it('should preserve real italic marks alongside escaped asterisks', () => {
const json = markdownManager.parse('\\*escaped\\* and *real*')
expect(json.content).toHaveLength(1)
const content = json.content[0].content!
expect(content).toHaveLength(2)
// The unmarked escaped text gets merged into a single node
expect(content[0].type).toBe('text')
expect(content[0].text).toBe('*escaped* and ')
// The real italic is still separate
expect(content[1]).toEqual({
type: 'text',
text: 'real',
marks: [{ type: 'italic' }],
})
})
})
describe('serializing: text with special chars → backslash-escaped markdown', () => {
it('should escape asterisks in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '*literal*' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\*literal\\*')
})
it('should escape underscores in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '_underscore_' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\_underscore\\_')
})
it('should escape backticks in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '`code`' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\`code\\`')
})
it('should escape backslashes in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '\\' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\\\')
})
it('should escape brackets in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '[bracket]' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\[bracket\\]')
})
it('should escape tildes in text nodes', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '~strike~' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('\\~strike\\~')
})
})
describe('round-trip: parse → serialize → parse stability', () => {
it('should round-trip a simple escaped asterisk string', () => {
const input = '\\*hello\\*'
const json = markdownManager.parse(input)
const md = markdownManager.serialize(json)
const json2 = markdownManager.parse(md)
expect(json2.content[0].content[0].text).toBe('*hello*')
})
it('should round-trip JSON with literal asterisk through serialize → parse', () => {
const input = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '*literal*' }],
},
],
}
const md = markdownManager.serialize(input)
const parsed = markdownManager.parse(md)
expect(parsed.content[0].content[0].text).toBe('*literal*')
})
it('should round-trip a double-backslash', () => {
const input = '\\\\\\\\'
const json = markdownManager.parse(input)
const md = markdownManager.serialize(json)
const json2 = markdownManager.parse(md)
expect(json2.content[0].content[0].text).toBe('\\\\')
})
})
describe('edge cases: code blocks and nested syntax', () => {
it('should NOT backslash-escape inside inline code marks (parsing)', () => {
const json = markdownManager.parse('`\\*not italic\\*`')
expect(json.content[0].content[0]).toEqual({
type: 'text',
text: '\\*not italic\\*',
marks: [{ type: 'code' }],
})
})
it('should NOT backslash-escape inside code blocks when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: '*not bold*' }],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('```\n*not bold*\n```')
})
it('should NOT backslash-escape inside inline code marks when serializing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '*not bold*',
marks: [{ type: 'code' }],
},
],
},
],
}
const markdown = markdownManager.serialize(json)
expect(markdown).toBe('`*not bold*`')
})
})
})
})

View File

@@ -0,0 +1,88 @@
import { Editor } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { Markdown } from '@tiptap/markdown'
import { afterEach, describe, expect, it } from 'vite-plus/test'
describe('empty markdown content', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('should create a valid document when initial content is an empty string', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown],
content: '',
contentType: 'markdown',
})
const json = editor.getJSON()
expect(json.type).toBe('doc')
expect(json.content).toBeDefined()
expect(json.content).toHaveLength(1)
expect(json.content![0].type).toBe('paragraph')
})
it('should allow inserting text after initialization with empty markdown content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContent('Hello')
const json = editor.getJSON()
expect((json.content![0].content![0] as any).text).toBe('Hello')
})
it('should allow inserting text with a paragraph command after initialization with empty markdown content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContentAt(0, { type: 'text', text: 'Hello' })
const json = editor.getJSON()
expect((json.content![0].content![0] as any).text).toBe('Hello')
})
it('should allow inserting markdown-formatted content after initialization with empty markdown content', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContent('**bold**', { contentType: 'markdown' })
const json = editor.getJSON()
const textNode = json.content![0].content![0] as any
expect(textNode.type).toBe('text')
expect(textNode.text).toBe('bold')
expect(textNode.marks?.[0]?.type).toBe('bold')
})
it('should allow inserting markdown-formatted content at a specific position after empty initialization', () => {
editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Markdown],
content: '',
contentType: 'markdown',
})
editor.commands.insertContentAt(0, '**bold**', { contentType: 'markdown' })
const json = editor.getJSON()
const textNode = json.content![0].content![0] as any
expect(textNode.type).toBe('text')
expect(textNode.text).toBe('bold')
expect(textNode.marks?.[0]?.type).toBe('bold')
})
})

View File

@@ -0,0 +1,329 @@
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
describe('Inline marks containing only punctuation', () => {
const extensions = [Document, Paragraph, Text, Bold, Italic]
const markdownManager = new MarkdownManager({ extensions })
describe('Bold marks with punctuation - whitespace-separated (CommonMark compliant)', () => {
it('should parse standalone bold punctuation marks', () => {
const markdown = '**)**'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
],
},
],
})
})
it('should parse bold punctuation when preceded by whitespace', () => {
const markdown = 'text **)**'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text ',
},
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
],
},
],
})
})
it('should parse bold punctuation when followed by whitespace', () => {
const markdown = '**)** text'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' text',
},
],
},
],
})
})
it('should parse bold punctuation when surrounded by whitespace', () => {
const markdown = 'text **)** more'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text ',
},
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' more',
},
],
},
],
})
})
it('should parse bold punctuation when preceded by other punctuation', () => {
const markdown = '[**)**]'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '[',
},
{
type: 'text',
text: ')',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ']',
},
],
},
],
})
})
it('should parse multiple bold punctuation marks', () => {
const markdown = '**,** **.**'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ',',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: ' ',
},
{
type: 'text',
text: '.',
marks: [{ type: 'bold' }],
},
],
},
],
})
})
})
describe('Bold marks without required whitespace (CommonMark limitation)', () => {
it('should NOT parse bold punctuation when directly preceded by alphanumeric characters', () => {
const markdown = 'text**)**'
const json = markdownManager.parse(markdown)
// Per CommonMark flanking rules, this is not recognized as bold
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text**)**',
},
],
},
],
})
})
it('should NOT parse bold punctuation when directly followed by alphanumeric characters', () => {
const markdown = '**)**text'
const json = markdownManager.parse(markdown)
// Per CommonMark flanking rules, this is not recognized as bold
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '**)**text',
},
],
},
],
})
})
it('should NOT parse bold punctuation when surrounded by alphanumeric characters', () => {
const markdown = 'text**)**text'
const json = markdownManager.parse(markdown)
// Per CommonMark flanking rules, this is not recognized as bold
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text**)**text',
},
],
},
],
})
})
})
describe('Italic marks with punctuation', () => {
it('should parse standalone italic punctuation', () => {
const markdown = '*)*'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: ')',
marks: [{ type: 'italic' }],
},
],
},
],
})
})
it('should parse italic punctuation with whitespace', () => {
const markdown = 'text *,* more'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text ',
},
{
type: 'text',
text: ',',
marks: [{ type: 'italic' }],
},
{
type: 'text',
text: ' more',
},
],
},
],
})
})
it('should NOT parse italic punctuation without whitespace', () => {
const markdown = 'text*,*text'
const json = markdownManager.parse(markdown)
expect(json).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'text*,*text',
},
],
},
],
})
})
})
describe('Roundtrip serialization', () => {
it('should maintain bold punctuation through parse-serialize cycle', () => {
const markdown = 'text **)** more'
const json = markdownManager.parse(markdown)
const serialized = markdownManager.serialize(json)
expect(serialized).toBe(markdown)
})
it('should maintain italic punctuation through parse-serialize cycle', () => {
const markdown = 'text *,* more'
const json = markdownManager.parse(markdown)
const serialized = markdownManager.serialize(json)
expect(serialized).toBe(markdown)
})
})
})

View File

@@ -0,0 +1,52 @@
import { Document } from '@tiptap/extension-document'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
const manager = (indentation?: { style?: 'space' | 'tab'; size?: number }) =>
new MarkdownManager({
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
...(indentation ? { indentation } : {}),
} as any)
const roundTrip = (mm: MarkdownManager, markdown: string) => mm.serialize(mm.parse(markdown) as any)
const ordered = ['1. one', ' 1. inner', '2. two'].join('\n')
const orderedWide = ['10. ten', ' 1. inner', '11. eleven'].join('\n')
const bullet = ['- one', ' - inner', '- two'].join('\n')
describe('list indentation on serialize', () => {
it('indents a nested ordered list to the marker width', () => {
expect(roundTrip(manager(), ordered)).toBe(ordered)
})
it('widens the indent for a marker wider than the indent size', () => {
expect(roundTrip(manager({ style: 'space', size: 2 }), orderedWide)).toBe(orderedWide)
})
it('keeps a configured indent that is already past the marker', () => {
expect(roundTrip(manager({ style: 'space', size: 4 }), ordered)).toBe(
['1. one', ' 1. inner', '2. two'].join('\n'),
)
})
it('keeps tab indentation on an ordered list, since a tab clears the marker', () => {
expect(roundTrip(manager({ style: 'tab', size: 1 }), orderedWide)).toBe(
['10. ten', '\t1. inner', '11. eleven'].join('\n'),
)
})
it('leaves bullet lists on the configured indent size', () => {
expect(roundTrip(manager({ style: 'space', size: 4 }), bullet)).toBe(
['- one', ' - inner', '- two'].join('\n'),
)
})
it('leaves bullet lists on tab indentation', () => {
expect(roundTrip(manager({ style: 'tab', size: 1 }), bullet)).toBe(
['- one', '\t- inner', '- two'].join('\n'),
)
})
})

View File

@@ -0,0 +1,127 @@
/**
* @vitest-environment happy-dom
*/
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('MarkdownManager Mixed Markdown + HTML', () => {
let manager: MarkdownManager
const basicExtensions = [Document, Paragraph, Text, Heading, Italic, HardBreak]
beforeEach(() => {
manager = new MarkdownManager({ extensions: basicExtensions })
})
it('parses heading with inline HTML <em> as italic', () => {
const md = '## hello <em>world</em>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
const heading = doc.content![0]
expect(heading.type).toBe('heading')
// Find the text node that contains 'world'
const textNodes = heading.content!.flatMap((n: any) =>
n.type === 'text' ? [n] : n.content || [],
)
const worldNode = textNodes.find((n: any) => n.text && n.text.includes('world'))
// Use a function-call assertion to avoid the "no-unused-expressions" lint error
expect(worldNode).not.toBe(undefined)
// The italic mark should be present
expect(Array.isArray(worldNode!.marks)).toBe(true)
const hasItalic = worldNode!.marks.some((m: any) => m.type === 'italic')
expect(hasItalic).toBe(true)
})
it('parses standalone inline HTML <em>world</em> as italic', () => {
const md = '<em>world</em>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
// Inline HTML typically produces a paragraph wrapper
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
const textNode = paragraph.content![0]
expect(textNode.text).toBe('world')
expect(Array.isArray(textNode.marks)).toBe(true)
const hasItalic = (textNode.marks || []).some((m: any) => m.type === 'italic')
expect(hasItalic).toBe(true)
})
it('parses markdown italic next to HTML italic correctly', () => {
const md = '*a* <em>b</em> *c*'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
const para = doc.content![0]
expect(para.type).toBe('paragraph')
// Collect texts and their mark states
const runs = para.content!.map((n: any) => ({ text: n.text, marks: n.marks || [] }))
// Expect there to be runs containing a, b, c
const texts = runs.map(r => (r.text || '').trim())
expect(texts).toContain('a')
expect(texts).toContain('b')
expect(texts).toContain('c')
;['a', 'b', 'c'].forEach(letter => {
const node = runs.find(r => (r.text || '').trim() === letter)
expect(node).not.toBe(undefined)
const hasItalic = ((node as any).marks || []).some((m: any) => m.type === 'italic')
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' }] },
])
})
it('parses inline HTML for an inline node and keeps the node', () => {
const md = 'a <br> b'
const doc = manager.parse(md)
expect(doc.content).toEqual([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'a ' },
{ type: 'hardBreak' },
{ type: 'text', text: ' b' },
],
},
])
})
})

View File

@@ -0,0 +1,73 @@
import { Mark } from '@tiptap/core'
import { Document } from '@tiptap/extension-document'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
// Adjacent text nodes sharing a mark type but differing in attributes must each
// render their own mark, instead of collapsing into one continuous run.
const TextStyle = Mark.create({
name: 'textStyle',
addAttributes() {
return {
color: { default: null },
}
},
renderMarkdown: (node, helpers) => {
const content = helpers.renderChildren(node)
const color = node.attrs?.color
if (!color) {
return content
}
return `<span style="color: ${color}">${content}</span>`
},
})
describe('serializing adjacent same-type marks with different attributes', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, TextStyle],
})
it('renders each distinct textStyle attribute set instead of collapsing them', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Test ',
marks: [{ type: 'textStyle', attrs: { color: '#9bbb59' } }],
},
{
type: 'text',
text: 'nested',
marks: [{ type: 'textStyle', attrs: { color: '#ff0000' } }],
},
{
type: 'text',
text: ' style',
marks: [{ type: 'textStyle', attrs: { color: '#9bbb59' } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// renderMarkdown must run once per distinct segment: three spans total,
// with the middle color present (proving the nested run rendered on its own).
const spanCount = (result.match(/<span /g) || []).length
expect(spanCount).toBe(3)
expect(result).toContain('color: #ff0000')
expect(result).toContain('Test')
expect(result).toContain('nested')
expect(result).toContain('style')
})
})

View File

@@ -0,0 +1,610 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { CodeBlock } from '@tiptap/extension-code-block'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { BlockMath } from '@tiptap/extension-mathematics'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
describe('Ordered list lazy continuation parsing', () => {
const extensions = [Document, Paragraph, Text, HardBreak, BulletList, OrderedList, ListItem]
it('keeps non-indented lazy continuation lines inside the current list item when breaks are enabled', () => {
const markdownManager = new MarkdownManager({
extensions,
markedOptions: {
breaks: true,
},
})
const markdown = [
'1. First item',
'Continuation for the first item.',
'',
'2. Second item',
'Continuation for the second item.',
].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'First item' },
{ type: 'hardBreak' },
{ type: 'text', text: 'Continuation for the first item.' },
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Second item' },
{ type: 'hardBreak' },
{ type: 'text', text: 'Continuation for the second item.' },
],
},
],
},
],
},
],
})
})
it('keeps nested bullet lists as block content inside ordered list items', () => {
const markdownManager = new MarkdownManager({
extensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', ' - inner', '2. two'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'one' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'inner' }],
},
],
},
],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'two' }],
},
],
},
],
},
],
})
})
it('stops lazy continuation at a heading line', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Bold,
Heading,
BulletList,
OrderedList,
ListItem,
],
})
const markdown = ['1. Plain text search', '### **Plain text search**'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Plain text search' }],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 3 },
content: [{ type: 'text', text: 'Plain text search', marks: [{ type: 'bold' }] }],
},
],
})
})
it('stops lazy continuation at heading lines of any level, for any item', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Heading,
BulletList,
OrderedList,
ListItem,
],
})
const markdown = ['1. one', '2. two', '###### six'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] }],
},
],
},
{
type: 'heading',
attrs: { level: 6 },
content: [{ type: 'text', text: 'six' }],
},
],
})
})
it('keeps hashtag-like lines that are not headings as lazy continuation', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Heading,
BulletList,
OrderedList,
ListItem,
],
markedOptions: {
breaks: true,
},
})
// "#hashtag" (no space) and "####### seven" (7 hashes) are not headings
// per CommonMark, so they must remain lazy continuation text.
const markdown = ['1. one', '#hashtag', '####### seven'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '#hashtag' },
{ type: 'hardBreak' },
{ type: 'text', text: '####### seven' },
],
},
],
},
],
},
],
})
})
it('parses indented heading lines as block content inside the list item', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
HardBreak,
Heading,
BulletList,
OrderedList,
ListItem,
],
})
const markdown = ['1. one', ' ### inner heading'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{
type: 'heading',
attrs: { level: 3 },
content: [{ type: 'text', text: 'inner heading' }],
},
],
},
],
},
],
})
})
const blockExtensions = [
Document,
Paragraph,
Text,
HardBreak,
Heading,
HorizontalRule,
CodeBlock,
Blockquote,
BulletList,
OrderedList,
ListItem,
BlockMath,
]
const listWithSingleItem = (text: string) => ({
type: 'orderedList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
},
],
})
it.each(['---', '***', '___', '* * *', '- - -', '_ _ _'])(
'stops lazy continuation at a "%s" thematic break',
thematicBreak => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', thematicBreak, 'Outside paragraph'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
listWithSingleItem('one'),
{ type: 'horizontalRule' },
{ type: 'paragraph', content: [{ type: 'text', text: 'Outside paragraph' }] },
],
})
},
)
it.each(['```', '~~~'])('stops lazy continuation at a "%s" code fence', fence => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', fence, 'const x = 1', fence].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
listWithSingleItem('one'),
{
type: 'codeBlock',
attrs: { language: null },
content: [{ type: 'text', text: 'const x = 1' }],
},
],
})
})
it('stops lazy continuation at a "$$" block math fence', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', '$$', 'x = 1', '$$'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [listWithSingleItem('one'), { type: 'blockMath', attrs: { latex: 'x = 1' } }],
})
})
it('parses an indented "$$" block math fence as block content inside the list item', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', ' $$', ' x = 1', ' $$'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'blockMath', attrs: { latex: 'x = 1' } },
],
},
],
},
],
})
})
it('stops lazy continuation at an unindented bullet list marker', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', '- bullet'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
listWithSingleItem('one'),
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'bullet' }] }],
},
],
},
],
})
})
it('parses indented non-dash thematic breaks as block content inside the list item', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', ' ***'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'horizontalRule' },
],
},
],
},
],
})
})
it('keeps an indented "---" directly below item text as paragraph text (setext ambiguity)', () => {
const markdownManager = new MarkdownManager({
extensions: blockExtensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', ' ---'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '---' },
],
},
],
},
],
},
],
})
})
it('keeps dash-prefixed text that is not a thematic break as lazy continuation', () => {
const markdownManager = new MarkdownManager({
extensions: blockExtensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', '---foo'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '---foo' },
],
},
],
},
],
},
],
})
})
it('keeps a setext-style "===" line as lazy continuation text', () => {
const markdownManager = new MarkdownManager({
extensions: blockExtensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', '==='].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: '===' },
],
},
],
},
],
},
],
})
})
it('keeps unindented blockquote lines inside the list item (matches marked 17.0.1)', () => {
const markdownManager = new MarkdownManager({ extensions: blockExtensions })
const markdown = ['1. one', '> quoted'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{
type: 'blockquote',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'quoted' }] }],
},
],
},
],
},
],
})
})
it('stops lazy continuation after a blank line', () => {
const markdownManager = new MarkdownManager({
extensions,
markedOptions: {
breaks: true,
},
})
const markdown = ['1. one', 'lazy continuation', '', 'Outside paragraph'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'one' },
{ type: 'hardBreak' },
{ type: 'text', text: 'lazy continuation' },
],
},
],
},
],
},
{
type: 'paragraph',
content: [{ type: 'text', text: 'Outside paragraph' }],
},
],
})
})
})

View File

@@ -0,0 +1,667 @@
import { Editor } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Italic } from '@tiptap/extension-italic'
import { Link } from '@tiptap/extension-link'
import { Paragraph } from '@tiptap/extension-paragraph'
import Strike from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { Markdown } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
/**
* Normalize marks order for deterministic comparison. Marks in the same position
* can be stored in any order by ProseMirror; sorting by type ensures
* deep-equality assertions don't flake on array ordering.
*/
const normalizeMarks = (node: any): any => {
if (Array.isArray(node)) {
return node.map(normalizeMarks)
}
if (!node || typeof node !== 'object') {
return node
}
return {
...node,
marks: node.marks
? [...node.marks].sort((a: any, b: any) => a.type.localeCompare(b.type))
: node.marks,
content: node.content ? node.content.map(normalizeMarks) : node.content,
}
}
describe('Overlapping marks serialization', () => {
const extensions = [Document, Paragraph, Text, Bold, Italic, Link]
const markdownManager = new MarkdownManager({ extensions })
/**
* Regression test for the original bug report.
*
* Steps to reproduce:
* 1. Toggle bold on
* 2. Type "123"
* 3. Toggle italic on
* 4. Type "456"
* 5. Toggle bold off
* 6. Type "789"
* 7. Toggle italic off
*
* Expected: **123*456***<em>789</em>
* Actual (buggy): **123*456**789*
*/
it('should correctly serialize overlapping bold and italic marks', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '123',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: '456',
marks: [{ type: 'bold' }, { type: 'italic' }],
},
{
type: 'text',
text: '789',
marks: [{ type: 'italic' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// The output **123*456**789* is invalid markdown because bold closes
// while italic is still open, breaking the nesting order.
// The valid markdown closes the nested italic inside bold first, then
// reopens the remaining italic segment with inline HTML to avoid ambiguous `***` parsing.
expect(result).toBe('**123*456***<em>789</em>')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Mirror of the original bug: italic starts first, bold is added, then italic ends.
*
* Steps: italic on → "abc" → bold on → "def" → italic off → "ghi" → bold off
*/
it('should correctly serialize italic starting before bold', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'abc',
marks: [{ type: 'italic' }],
},
{
type: 'text',
text: 'def',
marks: [{ type: 'italic' }, { type: 'bold' }],
},
{
type: 'text',
text: 'ghi',
marks: [{ type: 'bold' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// italic opens first with *, bold opens inside it, then the remaining bold span
// reopens with inline HTML after italic closes so the markdown remains unambiguous.
expect(result).toBe('*abc**def***<strong>ghi</strong>')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Bold and italic both start and end together on the same node (no overlap).
*/
it('should correctly serialize bold and italic on the same text', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello',
marks: [{ type: 'bold' }, { type: 'italic' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Both marks open and close on the same node — ***hello*** is valid CommonMark for bold+italic
expect(result).toBe('***hello***')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
// Mark order matches what a real editor emits: Link has priority 1000, so
// ProseMirror assigns it the lowest rank and stores it first in the marks
// array. The serializer must still place link as the outermost wrapper.
it('serializes italic inside a same-node link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
{ type: 'italic' },
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[*google*](https://google.com)')
expect(result).not.toBe('*[google](https://google.com)*')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes bold inside a same-node link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
{ type: 'bold' },
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[**google**](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('keeps html-reopened italic open across later text nodes before closing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789', marks: [{ type: 'italic' }] },
{ type: 'text', text: 'xyz', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' plain' },
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('**123*456***<em>789xyz</em> plain')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(
normalizeMarks({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789xyz', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' plain' },
],
},
],
}),
)
})
it('closes html-reopened italic correctly before a hard break', () => {
const markdownManagerWithBreak = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, HardBreak],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789', marks: [{ type: 'italic' }] },
{ type: 'hardBreak' },
{ type: 'text', text: 'after' },
],
},
],
}
const result = markdownManagerWithBreak.serialize(json)
expect(result).toBe('**123*456***<em>789</em> \nafter')
expect(normalizeMarks(markdownManagerWithBreak.parse(result))).toEqual(normalizeMarks(json))
})
it('does not switch non-bold-italic marks to html reopen mode', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'abc', marks: [{ type: 'italic' }] },
{ type: 'text', text: 'def', marks: [{ type: 'italic' }, { type: 'strike' }] },
{ type: 'text', text: 'ghi', marks: [{ type: 'strike' }] },
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
expect(result).toBe('*abc~~def~~*~~ghi~~')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes italic on the leading subset of link text inside the link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'italic' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[*google* search](https://google.com)')
expect(result).not.toBe('*[google* search](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes bold on the leading subset of link text inside the link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'bold' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[**google** search](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes partial italic in link text correctly from editor commands', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Italic, Link, Markdown],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'google search' }],
},
],
},
})
editor.commands.selectAll()
editor.commands.setLink({ href: 'https://google.com' })
editor.commands.setTextSelection({ from: 1, to: 7 })
editor.commands.setItalic()
const json = editor.getJSON()
const result = editor.getMarkdown()
const directResult = markdownManager.serialize(json)
const expectedRoundtripJson = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'italic' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
expect(result).toBe('[*google* search](https://google.com)')
expect(result).not.toBe('*[google* search](https://google.com)')
expect(directResult).toBe(result)
expect(normalizeMarks(editor.markdown?.parse(result))).toEqual(
normalizeMarks(expectedRoundtripJson),
)
editor.destroy()
})
/**
* Regression test for issue #7728.
* When bold, italic, and strike all close on the same node they must close
* LIFO (last-opened first-closed) to produce valid nesting.
*/
it('closes multiple simultaneously-ending marks in LIFO order (simple path)', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'bold ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'italics ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{
type: 'text',
text: 'strike',
marks: [{ type: 'bold' }, { type: 'italic' }, { type: 'strike' }],
},
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
expect(result).toBe('**bold *italics ~~strike~~***')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Regression test for the crossed-boundary LIFO bug.
* Bold (outer) and strike (inner) are both active; italic opens while both
* close at the same boundary. Previously-active marks must still close in
* LIFO order (strike first, bold last) even though a new mark is opening.
*
* Buggy output: **~~abc*def***~~*ghi* (bold closes before strike — invalid)
* Correct output: **~~abc*def*~~***ghi* (strike closes before bold — valid)
*/
it('closes previously-active marks in LIFO order on a crossed boundary', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'abc', marks: [{ type: 'bold' }, { type: 'strike' }] },
{
type: 'text',
text: 'def',
marks: [{ type: 'bold' }, { type: 'strike' }, { type: 'italic' }],
},
{ type: 'text', text: 'ghi', marks: [{ type: 'italic' }] },
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
// Strike must close before bold (LIFO). Bold then closes, and italic for
// "ghi" reopens with <em> to avoid the ambiguous *** sequence.
expect(result).toBe('**~~abc*def*~~**<em>ghi</em>')
// Buggy output (bold closes before strike): '**~~abc*def***~~<em>ghi</em>'
expect(result).not.toBe('**~~abc*def***~~<em>ghi</em>')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
})
/**
* Regression tests for issue #7682.
*
* Adjacent marks of the same type (e.g. links) with different attributes must
* be serialized as separate marks instead of being merged into one.
*/
describe('adjacent marks with different attributes', () => {
const extensions = [Document, Paragraph, Text, Link]
const markdownManager = new MarkdownManager({ extensions })
it('should serialize adjacent links with different href values as separate links', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'example',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
{
type: 'text',
text: 'github',
marks: [{ type: 'link', attrs: { href: 'https://github.com', title: null } }],
},
{
type: 'text',
text: 'tiptap',
marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Each adjacent link must be rendered with its own URL — not merged into one
expect(result).toBe(
'[example](https://example.com)[github](https://github.com)[tiptap](https://tiptap.dev)',
)
// Verify round-trip fidelity
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('should keep links with the same href and attributes merged', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'the same',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
{
type: 'text',
text: ' link',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Links with the same href and attributes are merged into one
expect(result).toBe('[the same link](https://example.com)')
// Verify re-serialization stability: parse then re-serialize produces the same output
const parsed = markdownManager.parse(result)
expect(markdownManager.serialize(parsed)).toBe(result)
})
it('should handle adjacent links with titles alongside links without titles', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'with title',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: 'Example' } }],
},
{
type: 'text',
text: ' no title',
marks: [{ type: 'link', attrs: { href: 'https://example.org', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Note: the leading space in " no title" is extracted outside the link
// brackets by the serializer's whitespace handling, resulting in a space
// between the two link markdown fragments.
expect(result).toBe(
'[with title](https://example.com "Example") [no title](https://example.org)',
)
// Verify re-serialization stability: parse then re-serialize produces the same output
const parsed = markdownManager.parse(result)
expect(markdownManager.serialize(parsed)).toBe(result)
})
})

View File

@@ -0,0 +1,267 @@
import { Blockquote } from '@tiptap/extension-blockquote'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('Paragraph Markdown Rendering', () => {
let markdownManager: MarkdownManager
beforeEach(() => {
markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
Heading,
Blockquote,
BulletList,
OrderedList,
ListItem,
],
})
})
describe('empty paragraphs at doc level', () => {
it('should omit a lone empty paragraph at doc root', () => {
const doc = {
type: 'doc',
content: [{ type: 'paragraph', content: [] }],
}
const raw = markdownManager.renderNodes(doc)
expect(raw).toBe('')
})
it('should preserve a single empty paragraph between two content paragraphs as blank markdown lines', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('First\n\n\n\nSecond')
})
it('should use &nbsp; to preserve additional blank paragraphs between two content paragraphs', () => {
const doc = {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('First\n\n\n\n&nbsp;\n\nSecond')
})
it('should keep a preserved blank paragraph marker after the first blank paragraph', () => {
const input = 'First\n\n&nbsp;\n\nSecond'
const json = markdownManager.parse(input)
const output = markdownManager.serialize(json)
expect(output.trim()).toBe('First\n\n\n\nSecond')
})
it('should preserve a trailing empty paragraph as trailing markdown spacing', () => {
const doc = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Markdown Test' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Click "Parse Markdown" to load content from the left panel.' },
],
},
{ type: 'paragraph', content: [] },
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe(
'# Markdown Test\n\nClick "Parse Markdown" to load content from the left panel.\n\n',
)
})
})
describe('empty paragraphs inside nested nodes', () => {
it('should not emit &nbsp; for an empty paragraph inside a bullet list item', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [] }],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('- ')
})
it('should not emit &nbsp; for an empty paragraph inside an ordered list item', () => {
const doc = {
type: 'doc',
content: [
{
type: 'orderedList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [] }],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('1. ')
})
it('should keep the first empty paragraph inside a blockquote empty', () => {
const doc = {
type: 'doc',
content: [
{
type: 'blockquote',
content: [{ type: 'paragraph', content: [] }],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('>')
})
it('should preserve multiple empty paragraphs inside a blockquote with &nbsp; after the first', () => {
const doc = {
type: 'doc',
content: [
{
type: 'blockquote',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toContain('> First')
expect(markdown).toContain('> Second')
expect(markdown).toContain('> &nbsp;')
})
it('should roundtrip multiple empty paragraphs inside a blockquote without losing any', () => {
const doc = {
type: 'doc',
content: [
{
type: 'blockquote',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
},
],
}
const markdown = markdownManager.serialize(doc)
const parsed = markdownManager.parse(markdown)
const blockquote = parsed.content![0]
expect(markdown).toBe('> First\n>\n>\n>\n> &nbsp;\n>\n> &nbsp;\n>\n> Second')
expect(blockquote.type).toBe('blockquote')
expect(blockquote.content).toHaveLength(5)
expect(
blockquote
.content!.slice(1, 4)
.every(node => node.type === 'paragraph' && node.content?.length === 0),
).toBe(true)
})
it('should render content paragraphs inside bullet list items normally', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] }],
},
{
type: 'listItem',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'World' }] }],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
expect(markdown).toBe('- Hello\n- World')
})
it('should roundtrip consecutive empty paragraphs inside a bullet list item', () => {
const doc = {
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [] },
{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] },
],
},
],
},
],
}
const markdown = markdownManager.serialize(doc)
const parsed = markdownManager.parse(markdown)
const listItem = parsed.content![0].content![0]
expect(markdown).toBe('- First\n\n \n\n &nbsp;\n\n Second')
expect(listItem.type).toBe('listItem')
expect(listItem.content).toHaveLength(4)
expect(listItem.content![1].content).toEqual([])
expect(listItem.content![2].content).toEqual([])
})
})
})

View File

@@ -0,0 +1,212 @@
/**
* @vitest-environment node
*/
import { Code } from '@tiptap/extension-code'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Strike } from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { beforeEach, describe, expect, it } from 'vite-plus/test'
describe('MarkdownManager Server-side Parsing', () => {
let manager: MarkdownManager
const basicExtensions = [Document, Paragraph, Text, Strike, Code, Heading]
beforeEach(() => {
manager = new MarkdownManager({ extensions: basicExtensions })
})
it('parses inline code with HTML-like tags and strike marks without error', () => {
const md = '`<h1>`~`<h6>`'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
expect(paragraph.content).toBeDefined()
// Find text nodes with code marks (inline code)
const codeTextNodes = paragraph.content!.filter(
(n: any) => n.type === 'text' && n.marks?.some((m: any) => m.type === 'code'),
)
expect(codeTextNodes.length).toBeGreaterThan(0)
// At least one code segment should contain an HTML-like tag
const hasHtmlTag = codeTextNodes.some(
(n: any) => n.text && (n.text.includes('<h1>') || n.text.includes('<h6>')),
)
expect(hasHtmlTag).toBe(true)
// All text content combined should include both HTML tags and the tilde
const allTextNodes = paragraph.content!.filter((n: any) => n.type === 'text')
const allText = allTextNodes.map((n: any) => n.text).join('')
expect(allText).toContain('h1')
expect(allText).toContain('h6')
expect(allText).toMatch(/~/)
})
it('parses inline code with HTML-like tags and double strike marks without error', () => {
const md = '`<h1>`~~`<h6>`'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
// Find text nodes with code marks
const codeTextNodes = paragraph.content!.filter(
(n: any) => n.type === 'text' && n.marks?.some((m: any) => m.type === 'code'),
)
expect(codeTextNodes.length).toBeGreaterThan(0)
// At least one code segment should contain an HTML-like tag
const hasHtmlTag = codeTextNodes.some(
(n: any) => n.text && (n.text.includes('<h1>') || n.text.includes('<h6>')),
)
expect(hasHtmlTag).toBe(true)
// All text should contain both HTML tags
const allTextNodes = paragraph.content!.filter((n: any) => n.type === 'text')
const allText = allTextNodes.map((n: any) => n.text).join('')
expect(allText).toContain('h1')
expect(allText).toContain('h6')
})
it('parses strike marks in normal text', () => {
const md = 'a~b'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
})
it('parses HTML heading tags with strike marks', () => {
const md = '<h1>~~test~~</h1>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
expect(paragraph.content).toBeDefined()
// In Node.js environment, HTML tags should be treated as literal text
const allText = paragraph
.content!.map((n: any) =>
n.text ? n.text : n.content?.map((c: any) => c.text).join('') || '',
)
.join('')
// Should contain the HTML tags as literal text
expect(allText).toContain('<h1>')
expect(allText).toContain('</h1>')
// Should contain 'test' text
expect(allText).toContain('test')
})
it('parses multiple inline code segments with HTML-like content and strike', () => {
const md = 'text `<h1>` more ~~text~~ and `<h2>`'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
// Find text nodes with code marks (inline code)
const codeTextNodes = paragraph.content!.filter(
(n: any) => n.type === 'text' && n.marks?.some((m: any) => m.type === 'code'),
)
expect(codeTextNodes.length).toBeGreaterThanOrEqual(2)
// Code text should contain literal HTML-like tags
expect(codeTextNodes[0].text).toBe('<h1>')
expect(codeTextNodes[1].text).toBe('<h2>')
// Find text nodes with strike marks
const textNodes = paragraph.content!.filter((n: any) => n.type === 'text')
const strikeTextNode = textNodes.find(
(n: any) =>
n.text && n.text.includes('text') && n.marks?.some((m: any) => m.type === 'strike'),
)
expect(strikeTextNode).toBeDefined()
})
it('preserves unknown angle-bracket placeholders as literal text in server environment', () => {
const md = '<enter existing CID here if available>\n\nother text after'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
expect(doc.content).toHaveLength(2)
// angle-bracket placeholder preserved as literal text
const firstPara = doc.content![0]
expect(firstPara.type).toBe('paragraph')
expect(firstPara.content).toHaveLength(1)
expect(firstPara.content![0].type).toBe('text')
expect(firstPara.content![0].text).toBe('<enter existing CID here if available>')
// trailing text after the blank line
const secondPara = doc.content![1]
expect(secondPara.type).toBe('paragraph')
expect(secondPara.content).toHaveLength(1)
expect(secondPara.content![0].type).toBe('text')
expect(secondPara.content![0].text).toBe('other text after')
})
it('parses HTML tags as plain text in server environment', () => {
const md = '<p>Hello</p>'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
expect(Array.isArray(doc.content)).toBe(true)
// The document should have a paragraph
const paragraph = doc.content![0]
expect(paragraph.type).toBe('paragraph')
expect(paragraph.content).toBeDefined()
// The HTML should be preserved as literal text (not parsed)
const textContent = paragraph.content!.map((n: any) => n.text).join('')
expect(textContent).toContain('<p>Hello</p>')
})
it('still parses markdown syntax correctly alongside HTML', () => {
const md = '<div>test</div> ~~world~~'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(doc.content).toBeDefined()
// Find all text nodes
const textNodes = doc.content!.flatMap((node: any) =>
node.content ? node.content.filter((n: any) => n.type === 'text') : [],
)
// Should have HTML as literal text
const hasHtmlText = textNodes.some((n: any) => n.text && n.text.includes('<div>test</div>'))
expect(hasHtmlText).toBe(true)
// Should have 'world' text (with or without strike mark depending on parsing)
const hasWorldText = textNodes.some((n: any) => n.text && n.text.includes('world'))
expect(hasWorldText).toBe(true)
})
})

View File

@@ -0,0 +1,182 @@
import { Document } from '@tiptap/extension-document'
import { BulletList, ListItem, TaskItem, TaskList } from '@tiptap/extension-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
const paragraph = (text: string) => ({
type: 'paragraph',
content: [{ type: 'text', text }],
})
const listItem = (text: string) => ({
type: 'listItem',
content: [paragraph(text)],
})
const taskItem = (text: string, ...nested: object[]) => ({
type: 'taskItem',
attrs: { checked: false },
content: [paragraph(text), ...nested],
})
describe('Task list with non-checkbox siblings in nested lists', () => {
const markdownManager = new MarkdownManager({
extensions: [
Document,
Paragraph,
Text,
BulletList,
ListItem,
TaskList,
TaskItem.configure({ nested: true }),
],
})
it('keeps a plain bullet that follows a checkbox item in a nested list', () => {
const markdown = ['- [ ] parent', ' - [ ] checkbox child', ' - plain child'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'taskList', content: [taskItem('checkbox child')] },
{ type: 'bulletList', content: [listItem('plain child')] },
),
],
},
],
})
})
it('keeps multiple trailing plain bullets in one bullet list', () => {
const markdown = ['- [ ] parent', ' - [ ] checkbox child', ' - plain 1', ' - plain 2'].join(
'\n',
)
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'taskList', content: [taskItem('checkbox child')] },
{ type: 'bulletList', content: [listItem('plain 1'), listItem('plain 2')] },
),
],
},
],
})
})
it('keeps a plain bullet in a deeply nested task list', () => {
const markdown = [
'- [ ] parent',
' - [ ] level2',
' - [ ] level3',
' - deep plain',
].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem('parent', {
type: 'taskList',
content: [
taskItem(
'level2',
{ type: 'taskList', content: [taskItem('level3')] },
{ type: 'bulletList', content: [listItem('deep plain')] },
),
],
}),
],
},
],
})
})
it('keeps a trailing paragraph that follows a nested checkbox item', () => {
const markdown = ['- [ ] parent', ' - [ ] child', ' trailing paragraph'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'taskList', content: [taskItem('child')] },
paragraph('trailing paragraph'),
),
],
},
],
})
})
it('keeps a plain bullet that precedes a checkbox item in a nested list', () => {
const markdown = ['- [ ] parent', ' - plain first', ' - [ ] checkbox after'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem(
'parent',
{ type: 'bulletList', content: [listItem('plain first')] },
{ type: 'taskList', content: [taskItem('checkbox after')] },
),
],
},
],
})
})
it('still parses a purely-checkbox nested list as a single task list', () => {
const markdown = ['- [ ] parent', ' - [ ] child 1', ' - [x] child 2'].join('\n')
expect(markdownManager.parse(markdown)).toEqual({
type: 'doc',
content: [
{
type: 'taskList',
content: [
taskItem('parent', {
type: 'taskList',
content: [
taskItem('child 1'),
{
type: 'taskItem',
attrs: { checked: true },
content: [paragraph('child 2')],
},
],
}),
],
},
],
})
})
it('round-trips the mixed nested list back to the original markdown', () => {
const markdown = ['- [ ] parent', ' - [ ] checkbox child', ' - plain child'].join('\n')
const json = markdownManager.parse(markdown)
expect(markdownManager.serialize(json).trim()).toBe(markdown)
})
})

View File

@@ -0,0 +1,156 @@
/**
* @vitest-environment happy-dom
*/
import { Node } from '@tiptap/core'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Text } from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
/**
* When markdown content contains text inside angle brackets that looks
* like an HTML tag but is not a recognized HTML element (and is not in
* the schema), marked passes it through as raw HTML. The browser's DOM
* parser then creates an unknown element with no text content, which is
* silently swallowed during schema-aware parsing.
*
* The expected behavior is that unrecognized HTML-like text should be
* preserved as literal plain text instead of being lost.
*/
describe('MarkdownManager unrecognized HTML tags', () => {
const basicExtensions = [Document, Paragraph, Text, Heading, Italic]
const collectText = (node: any): string => {
if (!node) {
return ''
}
if (typeof node === 'string') {
return node
}
if (Array.isArray(node)) {
return node.map(collectText).join('')
}
if (node.type === 'text' && typeof node.text === 'string') {
return node.text
}
if (Array.isArray(node.content)) {
return node.content.map(collectText).join('')
}
return ''
}
it('does not silently swallow text inside an unknown opening tag at block level', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
const md = '<enter existing CID here if available>\n\nother text after'
const doc = manager.parse(md)
expect(doc.type).toBe('doc')
expect(Array.isArray(doc.content)).toBe(true)
// The original "enter existing CID here if available" content must survive
// somewhere in the parsed document - it must not be silently dropped.
const text = collectText(doc)
expect(text).toContain('enter existing CID here if available')
expect(text).toContain('other text after')
})
it('does not silently swallow text inside an unknown opening tag inline', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
const md = 'before <enter existing CID here> after'
const doc = manager.parse(md)
const text = collectText(doc)
expect(text).toContain('before')
expect(text).toContain('enter existing CID here')
expect(text).toContain('after')
})
it('still parses recognized inline HTML elements normally', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
const md = 'hello <em>world</em>'
const doc = manager.parse(md)
const text = collectText(doc)
expect(text).toContain('hello')
expect(text).toContain('world')
})
it('does not render raw markup for recognized but empty HTML elements', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
// Empty <em></em> / <span></span> have no text content and no void element,
// but they are still real HTML – they must not be rendered as literal text.
;['<em></em>', '<em> </em>', '<span></span>'].forEach(md => {
const doc = manager.parse(md)
const text = collectText(doc)
expect(text).not.toContain('<em>')
expect(text).not.toContain('</em>')
expect(text).not.toContain('<span>')
expect(text).not.toContain('</span>')
})
})
it('does not flag hyphenated custom elements as unrecognized', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
// Hyphenated tag names are valid custom elements per the HTML spec – the
// browser constructs them as HTMLElement, not HTMLUnknownElement.
const doc = manager.parse('<my-mention></my-mention>')
const text = collectText(doc)
expect(text).not.toContain('<my-mention>')
expect(text).not.toContain('</my-mention>')
})
it('preserves angle-bracket text with spaces as literal text', () => {
const manager = new MarkdownManager({ extensions: basicExtensions })
// `<this is a placeholder>` contains spaces, so the tag name is invalid.
// The browser creates an HTMLUnknownElement and the manager should
// preserve the whole string as literal text instead of silently dropping it.
const doc = manager.parse('<this is a placeholder>')
const text = collectText(doc)
expect(text).toContain('<this is a placeholder>')
})
it('recognizes non-hyphenated custom tags declared in schema parseDOM', () => {
const Something = Node.create({
name: 'something',
group: 'inline',
inline: true,
content: 'text*',
renderHTML: () => ['something', 0],
parseHTML: () => [{ tag: 'something' }],
})
const SomethingWithHyphen = Node.create({
name: 'something-with-hyphen',
group: 'inline',
inline: true,
content: 'text*',
renderHTML: () => ['something-with-hyphen', 0],
parseHTML: () => [{ tag: 'something-with-hyphen' }],
})
const manager = new MarkdownManager({
extensions: [...basicExtensions, Something, SomethingWithHyphen],
})
// `<something>` is not a standard HTML tag (no hyphen), so the browser would
// normally create an HTMLUnknownElement. However, because the schema has
// a registered extension declaring parseDOM `{ tag: 'something' }`, the
// manager should treat it as a recognized element.
const doc = manager.parse(
'<something>happy</something><something attribute="a">happy</something><something-with-hyphen attribute="a">joyful</something-with-hyphen>',
)
const text = collectText(doc)
expect(text).toContain('happy')
expect(text).toContain('joyful')
expect(text).not.toContain('<something>')
expect(text).not.toContain('</something>')
expect(text).not.toContain('<something-with-hyphen>')
expect(text).not.toContain('</something-with-hyphen>')
})
})

View File

@@ -0,0 +1,523 @@
import type { JSONContent } from '@tiptap/core'
import {
createAtomBlockMarkdownSpec,
createBlockMarkdownSpec,
createInlineMarkdownSpec,
} from '@tiptap/core'
import { describe, expect, it } from 'vite-plus/test'
function measure(run: () => unknown): number {
const startedAt = performance.now()
run()
return performance.now() - startedAt
}
function expectLinearTokenization(run: (attributes: string) => unknown, attackUnit: string) {
run('warmup="true"')
const sizes = [768, 1536]
sizes.forEach(size => {
const attack = attackUnit.repeat(size)
const control = '-'.repeat(attack.length)
const controlDuration = measure(() => run(control))
const attackDuration = measure(() => run(attack))
expect(attackDuration).toBeLessThan(controlDuration * 20 + 100)
})
}
describe('Markdown Utilities', () => {
describe('createInlineMarkdownSpec', () => {
it('should create a valid spec for self-closing inline nodes', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
allowedAttributes: ['id', 'label'],
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('inline')
})
it('should create a valid spec for inline nodes with content', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'highlight',
selfClosing: false,
allowedAttributes: ['color'],
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('inline')
})
it('should handle tokenizer start detection', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
})
const src = '[mention id="test"] some other text'
const startIndex =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(src)
: src.indexOf(spec.markdownTokenizer.start!)
expect(startIndex).toBe(0)
const falseSrc = 'no mention here'
const noMatch =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(falseSrc)
: falseSrc.indexOf(spec.markdownTokenizer.start!)
expect(noMatch).toBe(-1)
})
it('should tokenize self-closing shortcodes correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
})
const token = spec.markdownTokenizer.tokenize(
'[mention id="test" label="Test User"]',
[],
null as any,
)
expect(token).toMatchObject({
type: 'mention',
raw: '[mention id="test" label="Test User"]',
content: '',
attributes: {
id: 'test',
label: 'Test User',
},
})
})
it('should tokenize shortcodes with content correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'highlight',
selfClosing: false,
})
const token = spec.markdownTokenizer.tokenize(
'[highlight color="yellow"]highlighted text[/highlight]',
[],
null as any,
)
expect(token).toMatchObject({
type: 'highlight',
raw: '[highlight color="yellow"]highlighted text[/highlight]',
content: 'highlighted text',
attributes: {
color: 'yellow',
},
})
})
it('should parse malformed shortcode attributes in linear time', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'probe',
selfClosing: true,
})
expectLinearTokenization(
attributes => spec.markdownTokenizer.tokenize(`[probe ${attributes}]`, [], null as any),
'0000000000000000',
)
})
it('should only parse shortcode attributes at attribute boundaries', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'probe',
selfClosing: true,
})
const token = spec.markdownTokenizer.tokenize(
'[probe prefix:id="ignored" label="kept"]',
[],
null as any,
)
expect(token).toMatchObject({ attributes: { label: 'kept' } })
})
it('should render self-closing shortcodes correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
allowedAttributes: ['id', 'label'],
})
const node = {
attrs: {
id: 'test',
label: 'Test User',
internalId: 'should-be-filtered', // This should not appear
},
}
const rendered = spec.renderMarkdown(node)
expect(rendered).toBe('[mention id="test" label="Test User"]')
})
it('should render shortcodes with content correctly', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'highlight',
selfClosing: false,
allowedAttributes: ['color'],
})
const node: JSONContent = {
content: [{ type: 'text', text: 'highlighted text' }],
attrs: {
color: 'yellow',
},
}
const rendered = spec.renderMarkdown(node)
expect(rendered).toBe('[highlight color="yellow"]highlighted text[/highlight]')
})
it('should skip attributes with default values when skipIfDefault is specified', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
selfClosing: true,
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
})
// When mentionSuggestionChar equals the default '@', it should be omitted
const nodeWithDefault = {
attrs: {
id: 'test',
label: 'Test User',
mentionSuggestionChar: '@',
},
}
const renderedWithDefault = spec.renderMarkdown(nodeWithDefault)
expect(renderedWithDefault).toBe('[mention id="test" label="Test User"]')
// When mentionSuggestionChar differs from default, it should be included
const nodeWithNonDefault = {
attrs: {
id: 'bug',
label: 'Bug',
mentionSuggestionChar: '#',
},
}
const renderedWithNonDefault = spec.renderMarkdown(nodeWithNonDefault)
expect(renderedWithNonDefault).toBe(
'[mention id="bug" label="Bug" mentionSuggestionChar="#"]',
)
})
it('should support custom parseAttributes and serializeAttributes for attribute name mapping', () => {
const spec = createInlineMarkdownSpec({
nodeName: 'mention',
name: '@',
selfClosing: true,
allowedAttributes: ['id', 'label', { name: 'mentionSuggestionChar', skipIfDefault: '@' }],
parseAttributes: (attrString: string) => {
const attrs: Record<string, any> = {}
const regex = /(?:^|\s)(\w+)=(?:"([^"]*)"|'([^']*)')/g
let match = regex.exec(attrString)
while (match !== null) {
const [, key, doubleQuoted, singleQuoted] = match
const value = doubleQuoted ?? singleQuoted
attrs[key === 'char' ? 'mentionSuggestionChar' : key] = value
match = regex.exec(attrString)
}
return attrs
},
serializeAttributes: (attrs: Record<string, any>) => {
return Object.entries(attrs)
.filter(([, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const serializedKey = key === 'mentionSuggestionChar' ? 'char' : key
return `${serializedKey}="${value}"`
})
.join(' ')
},
})
// Test serialization: mentionSuggestionChar should be serialized as 'char'
const nodeWithHashChar = {
attrs: {
id: 'bug',
mentionSuggestionChar: '#',
},
}
const rendered = spec.renderMarkdown(nodeWithHashChar)
expect(rendered).toBe('[@ id="bug" char="#"]')
// Test serialization: default '@' should be omitted
const nodeWithDefaultChar = {
attrs: {
id: 'Madonna',
mentionSuggestionChar: '@',
},
}
const renderedDefault = spec.renderMarkdown(nodeWithDefaultChar)
expect(renderedDefault).toBe('[@ id="Madonna"]')
// Test parsing: 'char' should be mapped to 'mentionSuggestionChar'
const token = spec.markdownTokenizer.tokenize('[@ id="feature" char="#"]', [], null as any)
expect(token).toMatchObject({
type: 'mention',
attributes: {
id: 'feature',
mentionSuggestionChar: '#',
},
})
// Test parsing: without char attribute
const tokenNoChar = spec.markdownTokenizer.tokenize('[@ id="Tom Cruise"]', [], null as any)
expect(tokenNoChar).toMatchObject({
type: 'mention',
attributes: {
id: 'Tom Cruise',
},
})
})
})
describe('createBlockMarkdownSpec', () => {
it('should create a valid spec for block nodes', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('block')
})
it('should handle tokenizer start detection', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
})
const src = ':::callout\ncontent\n:::'
const startIndex =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(src)
: src.indexOf(spec.markdownTokenizer.start!)
expect(startIndex).toBe(0)
const falseSrc = 'no callout here'
const noMatch =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(falseSrc)
: falseSrc.indexOf(spec.markdownTokenizer.start!)
expect(noMatch).toBe(-1)
})
it('should tokenize block syntax correctly', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
})
const mockLexer = {
blockTokens: (src: string) => [{ type: 'paragraph', text: src.trim() }],
inlineTokens: (src: string) => [{ type: 'text', text: src.trim() }],
}
const token = spec.markdownTokenizer.tokenize(
':::callout {type="info"}\nThis is a callout\n:::',
[],
mockLexer,
)
expect(token).toMatchObject({
type: 'callout',
attributes: { type: 'info' },
content: 'This is a callout',
})
expect(token).toHaveProperty('tokens')
expect(Array.isArray((token as any).tokens)).toBe(true)
expect((token as any).tokens).toHaveLength(1)
})
it('should parse malformed block attributes in linear time', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'probe',
})
const lexer = {
blockTokens: () => [],
inlineTokens: () => [],
}
expectLinearTokenization(
attributes =>
spec.markdownTokenizer.tokenize(`:::probe {${attributes}}\ncontent\n:::`, [], lexer),
'__QUOTED_0',
)
})
it('should render block syntax correctly', () => {
const spec = createBlockMarkdownSpec({
nodeName: 'callout',
allowedAttributes: ['type'],
})
const mockHelpers = {
renderChildren: () => 'This is a callout',
wrapInBlock: (content: string) => content,
indent: (content: string) => content,
}
const node = {
attrs: { type: 'info' },
content: [],
}
const rendered = spec.renderMarkdown(node, mockHelpers)
expect(rendered).toBe(':::callout {type="info"}\n\nThis is a callout\n\n:::')
})
})
describe('createAtomBlockMarkdownSpec', () => {
it('should create a valid spec for atomic block nodes', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
})
expect(spec).toHaveProperty('parseMarkdown')
expect(spec).toHaveProperty('markdownTokenizer')
expect(spec).toHaveProperty('renderMarkdown')
expect(spec.markdownTokenizer.level).toBe('block')
})
it('should handle tokenizer start detection', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
})
const src = ':::youtube {src="test"} :::'
const startIndex =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(src)
: src.indexOf(spec.markdownTokenizer.start!)
expect(startIndex).toBe(0)
const falseSrc = 'no youtube here'
const noMatch =
typeof spec.markdownTokenizer.start === 'function'
? spec.markdownTokenizer.start?.(falseSrc)
: falseSrc.indexOf(spec.markdownTokenizer.start!)
expect(noMatch).toBe(-1)
})
it('should tokenize atom block syntax correctly', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
})
const token = spec.markdownTokenizer.tokenize(
':::youtube {src="https://youtube.com/watch?v=test"} :::\n',
[],
null as any,
)
expect(token).toMatchObject({
type: 'youtube',
raw: ':::youtube {src="https://youtube.com/watch?v=test"} :::\n',
attributes: {
src: 'https://youtube.com/watch?v=test',
},
})
})
it('should parse malformed atom block attributes in linear time', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'probe',
})
expectLinearTokenization(
attributes =>
spec.markdownTokenizer.tokenize(`:::probe {${attributes}} :::\n`, [], null as any),
'__QUOTED_0',
)
})
it('should validate required attributes', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
requiredAttributes: ['src'],
})
// Should fail without required attribute
const tokenWithoutSrc = spec.markdownTokenizer.tokenize(
':::youtube {width="400"} :::\n',
[],
null as any,
)
expect(tokenWithoutSrc).toBe(undefined)
// Should succeed with required attribute
const tokenWithSrc = spec.markdownTokenizer.tokenize(
':::youtube {src="test"} :::\n',
[],
null as any,
)
expect(tokenWithSrc).not.toBe(undefined)
expect(tokenWithSrc).toHaveProperty('attributes')
expect((tokenWithSrc as any).attributes).toHaveProperty('src', 'test')
})
it('should render atom block syntax correctly', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
allowedAttributes: ['src', 'width'],
})
const node = {
attrs: {
src: 'https://youtube.com/watch?v=test',
width: '400',
internalId: 'should-be-filtered', // This should not appear
},
}
const rendered = spec.renderMarkdown(node)
expect(rendered).toBe(':::youtube {src="https://youtube.com/watch?v=test" width="400"} :::')
})
it('should apply default attributes', () => {
const spec = createAtomBlockMarkdownSpec({
nodeName: 'youtube',
defaultAttributes: { start: '0', width: '640' },
})
const mockHelpers = {
createNode: (name: string, attrs: any, content: any) => ({ type: name, attrs, content }),
parseInline: () => [],
parseChildren: () => [],
createTextNode: (text: string) => ({ type: 'text', text }),
applyMark: (mark: string, content: any) => ({ mark, content }),
}
const token = { attributes: { src: 'test' } }
const result = spec.parseMarkdown(token as any, mockHelpers as any)
expect((result as any).attrs).toMatchObject({
src: 'test',
start: '0',
width: '640',
})
})
})
})

View File

@@ -0,0 +1,178 @@
import { Bold } from '@tiptap/extension-bold'
import { Code } from '@tiptap/extension-code'
import { Document } from '@tiptap/extension-document'
import { Italic } from '@tiptap/extension-italic'
import { Paragraph } from '@tiptap/extension-paragraph'
import Strike from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
describe('Whitespace-only marked text nodes', () => {
const extensions = [Document, Paragraph, Text, Bold, Italic, Code, Strike]
const markdownManager = new MarkdownManager({ extensions })
const paragraph = (content: Array<Record<string, any>>) => ({
type: 'doc',
content: [{ type: 'paragraph', content }],
})
it.each([
['bold', 'a b'],
['italic', 'a b'],
['code', 'a b'],
['strike', 'a b'],
])('emits no delimiters for a whitespace-only %s node', (markType, expected) => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: markType }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe(expected)
})
it.each([
['tab', '\t'],
['non-breaking space', '\u00a0'],
['newline', '\n'],
])('emits no delimiters for a whitespace-only node containing a %s', (_name, whitespace) => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: whitespace, marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe(`a${whitespace}b`)
})
it('emits no delimiters for a multi-space whitespace-only node', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe('a b')
})
it('emits no delimiters when a whitespace-only node carries nested marks', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe('a b')
})
it('opens a continuing mark after the whitespace (Issue #8212)', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b', marks: [{ type: 'bold' }] },
]),
)
expect(markdown).toBe('a **b**')
})
it('keeps a continuing mark and drops the transient one', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b', marks: [{ type: 'bold' }] },
]),
)
expect(markdown).toBe('a **b**')
})
it('keeps a continuing italic when bold is the transient mark', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b', marks: [{ type: 'italic' }] },
]),
)
expect(markdown).toBe('a *b*')
})
it('spans a mark across the whitespace when it opened on an earlier node', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: 'b', marks: [{ type: 'bold' }] },
]),
)
expect(markdown).toBe('**a b**')
})
it('closes an already-open mark before a whitespace-only node that ends it', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'b' },
]),
)
expect(markdown).toBe('**a** b')
})
it('keeps expelling trailing whitespace out of a mark that has text', () => {
const markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'Label: ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'value' },
]),
)
expect(markdown).toBe('**Label:** value')
})
it.each([
['a single mark', [{ type: 'bold' }]],
['nested marks', [{ type: 'bold' }, { type: 'italic' }]],
])('survives repeated serialize/parse cycles with %s', (_name, marks) => {
// Parsing `****` reads it as literal asterisks, which the next serialize escapes.
// Assert the value on every cycle, not that it settles: the broken output settles too.
let markdown = markdownManager.serialize(
paragraph([
{ type: 'text', text: 'a' },
{ type: 'text', text: ' ', marks },
{ type: 'text', text: 'b' },
]),
)
for (let cycle = 0; cycle < 3; cycle += 1) {
expect(markdown).toBe('a b')
markdown = markdownManager.serialize(markdownManager.parse(markdown))
}
expect(markdown).toBe('a b')
})
it('still emphasises a node that is only punctuation', () => {
const markdown = markdownManager.serialize(
paragraph([{ type: 'text', text: ')', marks: [{ type: 'bold' }] }]),
)
expect(markdown).toBe('**)**')
})
})