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
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:
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
94
packages/markdown/tests/integration/blockquote.test.ts
Normal file
94
packages/markdown/tests/integration/blockquote.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
831
packages/markdown/tests/integration/conversion.test.ts
Normal file
831
packages/markdown/tests/integration/conversion.test.ts
Normal 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 markers.
|
||||
it('should preserve two empty paragraphs with blank spacing plus one 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 \n\nLine2')
|
||||
})
|
||||
|
||||
it('should parse markdown with one preserved blank paragraph back correctly', () => {
|
||||
const markdown = 'Line1\n\n \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 \n\n \n\n \n\n \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 \n\nLine2')
|
||||
})
|
||||
|
||||
it('should parse literal NBSP character (\\u00A0) as empty paragraph', () => {
|
||||
// Some markdown parsers may convert 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 < and > entities to literal < and > when parsing', () => {
|
||||
const markdown = 'foo <bar> 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 & entity to literal & when parsing', () => {
|
||||
const markdown = 'foo & 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 <bar> baz')
|
||||
})
|
||||
|
||||
it('should encode & back to & when serializing', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'foo & bar' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const markdown = markdownManager.serialize(json)
|
||||
expect(markdown).toBe('foo & bar')
|
||||
})
|
||||
|
||||
it('should roundtrip <bar> correctly', () => {
|
||||
const markdown = 'foo <bar> 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 <bar> baz')
|
||||
})
|
||||
|
||||
it('should roundtrip & correctly', () => {
|
||||
const markdown = 'foo & 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 & bar')
|
||||
})
|
||||
|
||||
it('should decode " entity to literal " when parsing', () => {
|
||||
const markdown = 'foo "bar" 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 " when parsing but serialize as literal "', () => {
|
||||
const markdown = 'foo "bar" 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', () => {
|
||||
// &lt; should decode to < (not to <)
|
||||
const markdown = 'foo &lt; bar'
|
||||
const json = markdownManager.parse(markdown)
|
||||
expect(json.content[0].content[0].text).toBe('foo < bar')
|
||||
|
||||
// Serializing should re-encode the & in <
|
||||
const serialized = markdownManager.serialize(json)
|
||||
expect(serialized).toBe('foo &lt; bar')
|
||||
})
|
||||
|
||||
it('should preserve empty paragraph behavior', () => {
|
||||
const markdown = 'Line1\n\n \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 marker).
|
||||
const serialized = markdownManager.serialize(json)
|
||||
expect(serialized).toBe('Line1\n\n\n\nLine2')
|
||||
})
|
||||
|
||||
it('should roundtrip literal &nbsp; without it being treated as an empty paragraph marker', () => {
|
||||
// A user writing &nbsp; in markdown intends for the text " " to display.
|
||||
// decodeHtmlEntities decodes & → &, producing text content " ".
|
||||
// On serialization, encodeHtmlEntities re-encodes & → &, restoring &nbsp;.
|
||||
// The intermediate " " text must NOT be confused with the empty-paragraph marker.
|
||||
const markdown = 'before &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 after')
|
||||
|
||||
const serialized = markdownManager.serialize(json)
|
||||
expect(serialized).toBe('before &nbsp; after')
|
||||
})
|
||||
|
||||
it('should preserve a standalone literal &nbsp; paragraph as text', () => {
|
||||
const markdown = '&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: ' ' }])
|
||||
|
||||
const serialized = markdownManager.serialize(json)
|
||||
expect(serialized).toBe('&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*`')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
88
packages/markdown/tests/integration/empty-content.test.ts
Normal file
88
packages/markdown/tests/integration/empty-content.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
52
packages/markdown/tests/integration/list-indentation.test.ts
Normal file
52
packages/markdown/tests/integration/list-indentation.test.ts
Normal 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
127
packages/markdown/tests/integration/mixed-html.test.ts
Normal file
127
packages/markdown/tests/integration/mixed-html.test.ts
Normal 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' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
667
packages/markdown/tests/integration/overlapping-marks.test.ts
Normal file
667
packages/markdown/tests/integration/overlapping-marks.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
267
packages/markdown/tests/integration/paragraph.test.ts
Normal file
267
packages/markdown/tests/integration/paragraph.test.ts
Normal 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 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 \n\nSecond')
|
||||
})
|
||||
|
||||
it('should keep a preserved blank paragraph marker after the first blank paragraph', () => {
|
||||
const input = 'First\n\n \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 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 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 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('> ')
|
||||
})
|
||||
|
||||
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> \n>\n> \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 \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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
212
packages/markdown/tests/integration/server-side-parsing.test.ts
Normal file
212
packages/markdown/tests/integration/server-side-parsing.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
156
packages/markdown/tests/integration/unknown-html-tags.test.ts
Normal file
156
packages/markdown/tests/integration/unknown-html-tags.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
523
packages/markdown/tests/integration/utilities.test.ts
Normal file
523
packages/markdown/tests/integration/utilities.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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('**)**')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user