import { type AnyExtension, createBlockMarkdownSpec, createInlineMarkdownSpec, Extension, Node, } from '@tiptap/core' import { Bold } from '@tiptap/extension-bold' import { Document } from '@tiptap/extension-document' 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 Strike from '@tiptap/extension-strike' import { Text } from '@tiptap/extension-text' import Underline from '@tiptap/extension-underline' import { Youtube } from '@tiptap/extension-youtube' import { MarkdownManager } from './MarkdownManager.js' import { Marked } from 'marked' import { beforeEach, describe, expect, it } from 'vite-plus/test' // Create test extensions const TestCallout = Node.create({ name: 'callout', group: 'block', content: 'block+', defining: true, addAttributes() { return { type: { default: 'info', }, } }, ...createBlockMarkdownSpec({ nodeName: 'callout', allowedAttributes: ['type'], }), }) const TestTag = Node.create({ name: 'tag', group: 'inline', inline: true, atom: true, addAttributes() { return { label: { type: null, }, } }, ...createInlineMarkdownSpec({ nodeName: 'tag', selfClosing: true, allowedAttributes: ['label'], }), }) const TestProbe = Extension.create({ name: 'probeBlock', markdownTokenName: 'probeBlock', parseMarkdown: (token, helpers) => { return { type: 'paragraph', content: helpers.parseInline(token.tokens || []), } }, markdownTokenizer: { name: 'probeBlock', level: 'block', start: ':::probe ', tokenize: (src, _tokens, lexer) => { const match = src.match(/^:::probe\s+(.+?)\s+:::/) if (!match) { return undefined } return { type: 'probeBlock', raw: match[0], text: match[1], tokens: lexer.inlineTokens(match[1]), } }, }, }) describe('MarkdownManager Direct Tests', () => { let markdownManager: MarkdownManager let basicExtensions: AnyExtension[] let extendedExtensions: AnyExtension[] beforeEach(() => { // Basic extensions for standard markdown basicExtensions = [ Document, Paragraph, Text, Heading, Bold, Italic, Strike, Link, BulletList, OrderedList, ListItem, Underline, ] // Extended extensions with custom markdown features extendedExtensions = [ ...basicExtensions, TaskList, TaskItem, Mention.configure({ HTMLAttributes: { class: 'mention', }, }), Youtube.configure({ width: 640, height: 360, }), TestCallout, TestTag, ] }) describe('Basic Markdown Parsing', () => { beforeEach(() => { markdownManager = new MarkdownManager({ extensions: basicExtensions }) }) it('should parse simple text', () => { const markdown = 'Hello world' const doc = markdownManager.parse(markdown) expect(doc.type).toBe('doc') expect(Array.isArray(doc.content)).toBe(true) expect(doc.content![0].type).toBe('paragraph') expect(doc.content![0].content![0].text).toBe('Hello world') }) it('should parse headings', () => { const markdown = `# Heading 1 ## Heading 2` const doc = markdownManager.parse(markdown) expect(doc.content).toHaveLength(2) expect(doc.content![0].type).toBe('heading') expect(doc.content![0].attrs!.level).toBe(1) expect(doc.content![0].content![0].text).toBe('Heading 1') expect(doc.content![1].type).toBe('heading') expect(doc.content![1].attrs!.level).toBe(2) expect(doc.content![1].content![0].text).toBe('Heading 2') }) it('should parse multiple paragraphs', () => { const markdown = `First paragraph. Second paragraph.` const doc = markdownManager.parse(markdown) expect(doc.content).toHaveLength(2) expect(doc.content![0].type).toBe('paragraph') expect(doc.content![0].content![0].text).toBe('First paragraph.') expect(doc.content![1].type).toBe('paragraph') expect(doc.content![1].content![0].text).toBe('Second paragraph.') }) it('keeps inline tokens after ordered list tokenization', () => { const markdown = `1. Item 1 - Nested bullet item **After** ordered list` const doc = markdownManager.parse(markdown) expect(doc.content).toHaveLength(2) expect(doc.content![0].type).toBe('orderedList') expect(doc.content![1]).toEqual({ type: 'paragraph', content: [ { type: 'text', text: 'After', marks: [{ type: 'bold' }], }, { type: 'text', text: ' ordered list', }, ], }) }) it('matches isolated Marked instance output for ordered-list parsing', () => { const markdown = `1. Item 1 - Nested bullet item **After** ordered list` const isolatedManager = new MarkdownManager({ marked: new Marked() as unknown as typeof import('marked').marked, extensions: basicExtensions, }) expect(markdownManager.parse(markdown)).toEqual(isolatedManager.parse(markdown)) }) it('keeps later inline parsing stable after a custom tokenizer uses inlineTokens', () => { const manager = new MarkdownManager({ extensions: [...basicExtensions, TestProbe] }) const markdown = `:::probe **Probe** text ::: **After** custom tokenizer` const doc = manager.parse(markdown) expect(doc.content).toEqual([ { type: 'paragraph', content: [ { type: 'text', text: 'Probe', marks: [{ type: 'bold' }], }, { type: 'text', text: ' text', }, ], }, { type: 'paragraph', content: [ { type: 'text', text: 'After', marks: [{ type: 'bold' }], }, { type: 'text', text: ' custom tokenizer', }, ], }, ]) }) it('runs custom tokenizers registered on an injected Marked instance', () => { // Regression: custom tokenizers must still fire when a dedicated `marked` // instance is injected, not only on the default shared instance. const manager = new MarkdownManager({ marked: new Marked() as unknown as typeof import('marked').marked, extensions: [...basicExtensions, TestProbe], }) const doc = manager.parse(':::probe **Probe** text :::') expect(doc.content).toEqual([ { type: 'paragraph', content: [ { type: 'text', text: 'Probe', marks: [{ type: 'bold' }], }, { type: 'text', text: ' text', }, ], }, ]) }) }) describe('simple nested Marks parsing', () => { beforeEach(() => { markdownManager = new MarkdownManager({ extensions: basicExtensions }) }) const nestedMarkdown = `**...++abc++**` const expectedJSON = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: '...', marks: [{ type: 'bold' }], }, { type: 'text', text: 'abc', marks: [{ type: 'underline' }, { type: 'bold' }], }, ], }, ], } it('should convert nested markdown to expected JSON structure', () => { const json = markdownManager.parse(nestedMarkdown) expect(json).toEqual(expectedJSON) }) it('should convert nested JSON structure back to expected markdown', () => { const md = markdownManager.serialize(expectedJSON) expect(md).toEqual(nestedMarkdown) }) }) describe('complex nested Marks parsing', () => { beforeEach(() => { markdownManager = new MarkdownManager({ extensions: basicExtensions }) }) const nestedMarkdown = `**...~~++abc*abc*++~~**` const expectedJSON = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: '...', marks: [{ type: 'bold' }], }, { type: 'text', text: 'abc', marks: [{ type: 'underline' }, { type: 'strike' }, { type: 'bold' }], }, { type: 'text', text: 'abc', marks: [ { type: 'italic' }, { type: 'underline' }, { type: 'strike' }, { type: 'bold' }, ], }, ], }, ], } it('should convert nested markdown to expected JSON structure', () => { const json = markdownManager.parse(nestedMarkdown) expect(json).toEqual(expectedJSON) }) it('should convert nested JSON structure back to expected markdown', () => { const md = markdownManager.serialize(expectedJSON) expect(md).toEqual(nestedMarkdown) }) }) describe('Extended Markdown Parsing', () => { beforeEach(() => { markdownManager = new MarkdownManager({ extensions: extendedExtensions }) }) it('should parse mentions', () => { const markdown = '[@ id="user123" label="John Doe"] said hello' const doc = markdownManager.parse(markdown) expect(doc.content![0].type).toBe('paragraph') expect(doc.content![0].content![0].type).toBe('mention') expect(doc.content![0].content![0].attrs!.id).toBe('user123') expect(doc.content![0].content![0].attrs!.label).toBe('John Doe') expect(doc.content![0].content![1].text).toBe(' said hello') }) it('should parse YouTube videos', () => { const markdown = ':::youtube {src="https://youtube.com/watch?v=test"} :::' const doc = markdownManager.parse(markdown) expect(doc.content![0].type).toBe('youtube') expect(doc.content![0].attrs!.src).toBe('https://youtube.com/watch?v=test') }) it('should handle complex documents with mixed content', () => { const markdown = `# Project Title This is a description with [@ id="user" label="User Name"]. :::youtube {src="https://youtube.com/watch?v=example"} ::: ## Notes Final paragraph.` const doc = markdownManager.parse(markdown) expect(doc.content).toHaveLength(5) expect(doc.content![0].type).toBe('heading') expect(doc.content![1].type).toBe('paragraph') expect(doc.content![1].content![0].type).toBe('text') expect(doc.content![1].content![1].type).toBe('mention') expect(doc.content![2].type).toBe('youtube') expect(doc.content![3].type).toBe('heading') expect(doc.content![4].type).toBe('paragraph') }) }) describe('Markdown Rendering', () => { beforeEach(() => { markdownManager = new MarkdownManager({ extensions: extendedExtensions }) }) it('should render simple text', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }], }, ], } const markdown = markdownManager.renderNodes(doc) expect(markdown).toBe('Hello world') }) it('should return empty string when serializing empty document', () => { // When the editor is empty, it contains a doc with an empty paragraph const emptyDoc = { type: 'doc', content: [ { type: 'paragraph', content: [], }, ], } const markdown = markdownManager.serialize(emptyDoc) expect(markdown).toBe('') }) it('should preserve blank lines between paragraphs with content', () => { // Multiple paragraphs with content should preserve blank lines const doc = { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'First paragraph' }], }, { type: 'paragraph', content: [], }, { type: 'paragraph', content: [{ type: 'text', text: 'Second paragraph' }], }, ], } const markdown = markdownManager.serialize(doc) expect(markdown).toBe('First paragraph\n\n\n\nSecond paragraph') }) it('should move trailing whitespace outside of mark closing (Issue #7180)', () => { // When text has trailing space and bold mark, output should be "**text** " not "**text **" const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'bold text ', marks: [{ type: 'bold' }], }, { type: 'text', text: 'continues here', }, ], }, ], } const markdown = markdownManager.renderNodes(doc) // The trailing space should be outside the ** markers expect(markdown).toBe('**bold text** continues here') }) it('should move leading whitespace outside of mark opening (Issue #7180)', () => { // When text has leading space and bold mark, output should be " **text**" not "** text**" const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'normal text', }, { type: 'text', text: ' bold text', marks: [{ type: 'bold' }], }, ], }, ], } const markdown = markdownManager.renderNodes(doc) // The leading space should be outside the ** markers expect(markdown).toBe('normal text **bold text**') }) it('should handle both leading and trailing whitespace in marked text (Issue #7180)', () => { // Text like " bold " with bold mark should output " **bold** " not "** bold **" const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'before', }, { type: 'text', text: ' bold ', marks: [{ type: 'bold' }], }, { type: 'text', text: 'after', }, ], }, ], } const markdown = markdownManager.renderNodes(doc) // Both leading and trailing spaces should be outside the ** markers expect(markdown).toBe('before **bold** after') }) it('should handle trailing whitespace in italic marks (Issue #7180)', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'italic text ', marks: [{ type: 'italic' }], }, { type: 'text', text: 'normal', }, ], }, ], } const markdown = markdownManager.renderNodes(doc) expect(markdown).toBe('*italic text* normal') }) it('should handle whitespace at end of paragraph with mark (Issue #7180)', () => { // Bold text with trailing space at end of paragraph const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'some text ', marks: [{ type: 'bold' }], }, ], }, ], } const markdown = markdownManager.renderNodes(doc) // Trailing space should be outside the marks expect(markdown).toBe('**some text** ') }) it('should render nested marks with correct tag order', () => { // Marks come in ProseMirror's canonical order (rank ascending) — Bold is // registered before Strike so it gets the lower rank. The serializer // places lower-rank marks outermost, mirroring how a real editor would // wrap the text: bold outer, strike inner. const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Test: ', marks: [], }, { type: 'text', text: 'abcd', marks: [ { type: 'bold' }, // Opens first: ** { type: 'strike' }, // Opens second: ~ ], }, { type: 'text', text: ' end.', marks: [], }, ], }, ], } const docAtEnd = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'abcd', marks: [ { type: 'bold' }, // Opens first: ** { type: 'strike' }, // Opens second: ~ ], }, ], }, ], } expect(markdownManager.renderNodes(doc)).to.equal('Test: **~~abcd~~** end.') expect(markdownManager.renderNodes(docAtEnd)).to.equal('**~~abcd~~**') }) it('should render headings', () => { const doc = { type: 'doc', content: [ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Main Title' }], }, { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Subtitle' }], }, ], } const markdown = markdownManager.renderNodes(doc) expect(markdown).toBe('# Main Title\n\n## Subtitle') }) it('should render mentions', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'mention', attrs: { id: 'user123', label: 'John Doe' }, }, { type: 'text', text: ' said hello' }, ], }, ], } const markdown = markdownManager.renderNodes(doc) expect(markdown).toBe('[@ id="user123" label="John Doe"] said hello') }) it('should render YouTube videos', () => { const doc = { type: 'doc', content: [ { type: 'youtube', attrs: { src: 'https://youtube.com/watch?v=test' }, }, ], } const markdown = markdownManager.renderNodes(doc) expect(markdown).toBe(':::youtube {src="https://youtube.com/watch?v=test"} :::') }) }) it('does not reopen marks after inline atom that does not carry them', () => { const doc = { type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'Bold ', marks: [{ type: 'bold' }] }, { type: 'tag', attrs: { label: 'node' } }, { type: 'text', text: ' some text' }, ], }, ], } const markdown = markdownManager.renderNodes(doc) // Trailing space is moved outside bold markers (per Issue #7180 whitespace convention) expect(markdown).toBe('**Bold** [tag label="node"] some text') }) describe('Round-trip Tests', () => { beforeEach(() => { markdownManager = new MarkdownManager({ extensions: extendedExtensions }) }) const testCases = [ { name: 'simple text', markdown: 'Hello world', }, { name: 'headings', markdown: '# Main Title\n\n## Subtitle', }, { name: 'mention', markdown: '[@ id="user123" label="John Doe"] said hello', }, { name: 'youtube video', markdown: ':::youtube {src="https://youtube.com/watch?v=test"} :::', }, { name: 'mixed content', markdown: '# Title\n\nHello [@ id="user" label="User"].\n\n:::youtube {src="https://youtube.com/watch?v=example"} :::', }, ] testCases.forEach(({ name, markdown }) => { it(`should round-trip ${name}`, () => { const doc = markdownManager.parse(markdown) const roundtripMarkdown = markdownManager.renderNodes(doc) // Normalize whitespace for comparison const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim() expect(normalizeWhitespace(roundtripMarkdown)).toBe(normalizeWhitespace(markdown)) }) }) }) describe('Extension Integration', () => { it('should work with different extension combinations', () => { // Test with minimal extensions const minimalExtensions = [Document, Paragraph, Text] const minimalManager = new MarkdownManager({ extensions: minimalExtensions }) const simpleDoc = minimalManager.parse('Hello world') expect(simpleDoc.content![0].type).toBe('paragraph') // Test with extended extensions const extendedManager = new MarkdownManager({ extensions: extendedExtensions }) const complexDoc = extendedManager.parse('# Title\n\n[@ id="user" label="User"]') expect(complexDoc.content![0].type).toBe('heading') expect(complexDoc.content![1].content![0].type).toBe('mention') }) it('should handle unknown markdown gracefully', () => { const manager = new MarkdownManager({ extensions: basicExtensions }) // Test various unknown markdown patterns that should be preserved as text const testCases = [ 'Hello [unknown syntax] world', 'Text with [custom element] here', 'Some [shortcode attr="value"] content', 'Using [component prop="test"] in text', ] testCases.forEach(markdown => { const doc = manager.parse(markdown) // Should treat it as regular text since it's not recognized markdown expect(doc.content![0].type).toBe('paragraph') expect(doc.content![0].content![0].text).toBe(markdown) }) }) }) describe('Performance Tests', () => { beforeEach(() => { markdownManager = new MarkdownManager({ extensions: extendedExtensions }) }) it('should handle large documents efficiently', () => { // Generate a large markdown document const sections = [] for (let i = 1; i <= 100; i += 1) { sections.push(`## Section ${i}`) sections.push(`This is paragraph ${i} with some **bold** text.`) sections.push(`And mention [@ id="user${i}" label="User ${i}"].`) } const largeMarkdown = sections.join('\n\n') const startTime = Date.now() const doc = markdownManager.parse(largeMarkdown) const parseTime = Date.now() - startTime const renderStartTime = Date.now() const rendered = markdownManager.renderNodes(doc) const renderTime = Date.now() - renderStartTime // Basic performance expectations (these would need to be tuned) expect(parseTime).toBeLessThan(1000) // Less than 1 second expect(renderTime).toBeLessThan(1000) // Less than 1 second expect(doc.content!.length).toBeGreaterThan(200) // Should have parsed many nodes expect(rendered).toContain('Section 1') expect(rendered).toContain('Section 100') }) it('should handle deeply nested content', () => { const nestedMarkdown = `# Level 1 :::callout {type="info"} ## Level 2 inside callout Some text with [@ id="user" label="User"]. :::youtube {src="https://youtube.com/watch?v=test"} ::: :::` const doc = markdownManager.parse(nestedMarkdown) const rendered = markdownManager.renderNodes(doc) expect(doc.content!.length).toBeGreaterThan(1) expect(rendered).toContain('Level 1') expect(rendered).toContain('Level 2') }) }) describe('configured extension context for markdown hooks', () => { const createParagraphWithUrlHooks = () => Node.create({ name: 'paragraph', group: 'block', content: 'inline*', addOptions() { return { repairUrl: (url: string) => url, serializeMarkdownUrl: (url: string) => url, } }, parseMarkdown(token, helpers) { return helpers.createNode( this.name, { url: this.options.repairUrl(token.text || '') }, helpers.parseInline(token.tokens || []), ) }, renderMarkdown(node) { return this.options.serializeMarkdownUrl(node.attrs?.url) }, }) const configuredParagraph = createParagraphWithUrlHooks().configure({ repairUrl: (url: string) => `https://example.com/${url}`, serializeMarkdownUrl: (url: string) => `<${url}>`, }) const createManager = (paragraph: AnyExtension) => new MarkdownManager({ extensions: [paragraph, Text], }) it('exposes configured options and name while parsing and serializing', () => { const manager = createManager(configuredParagraph) const parsed = manager.parse('asset.png') expect(parsed).toEqual({ type: 'doc', content: [ { type: 'paragraph', attrs: { url: 'https://example.com/asset.png' }, content: [{ type: 'text', text: 'asset.png' }], }, ], }) expect( manager.serialize({ type: 'paragraph', attrs: { url: 'https://example.com/asset.png' }, content: [{ type: 'text', text: 'asset.png' }], }), ).toBe('') }) it('keeps independent configured instances isolated', () => { const first = createParagraphWithUrlHooks().configure({ repairUrl: (url: string) => `https://first.example/${url}`, serializeMarkdownUrl: (url: string) => ``, }) const second = createParagraphWithUrlHooks().configure({ repairUrl: (url: string) => `https://second.example/${url}`, serializeMarkdownUrl: (url: string) => ``, }) expect(createManager(first).parse('asset.png').content![0].attrs).toEqual({ url: 'https://first.example/asset.png', }) expect(createManager(second).parse('asset.png').content![0].attrs).toEqual({ url: 'https://second.example/asset.png', }) }) it('merges chained configure() calls into markdown hooks', () => { const paragraph = createParagraphWithUrlHooks() .configure({ repairUrl: (url: string) => `https://first.example/${url}`, }) .configure({ serializeMarkdownUrl: (url: string) => `<${url}>`, repairUrl: (url: string) => `https://chained.example/${url}`, }) const manager = createManager(paragraph) const parsed = manager.parse('asset.png') expect(parsed.content![0].attrs).toEqual({ url: 'https://chained.example/asset.png' }) expect(manager.serialize(parsed.content![0])).toBe('') }) it('lets markdown hooks call their parent with the configured context', () => { const paragraph = createParagraphWithUrlHooks() .extend({ parseMarkdown(token, helpers) { return this.parent?.(token, helpers) }, renderMarkdown(node, helpers, ctx) { return this.parent?.(node, helpers, ctx) ?? '' }, }) .configure({ repairUrl: (url: string) => `https://example.com/${url}`, serializeMarkdownUrl: (url: string) => `<${url}>`, }) const manager = createManager(paragraph) const parsed = manager.parse('asset.png') expect(parsed.content![0].attrs).toEqual({ url: 'https://example.com/asset.png' }) expect(manager.serialize(parsed.content![0])).toBe('') }) }) })