import { describe, expect, it } from 'vitest'; // Import the converter DIRECTLY from src (NOT the docmost-client barrel, which // pulls in collaboration.ts and mutates the global DOM at import time), matching // the other converter unit tests. markdownToProseMirror is imported for the // round-trip cases; loading it mutates the global DOM via jsdom (required for // @tiptap/html's generateJSON under Node) — this is expected. import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js'; import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js'; // Wrap one or more nodes in a minimal ProseMirror doc. The top-level converter // joins doc children with "\n\n" then .trim()s, so a single-node doc yields // exactly that node's rendered (trimmed) string. const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes }); const text = (t: string) => ({ type: 'text', text: t }); const para = (...inline: any[]) => ({ type: 'paragraph', content: inline }); // Run a full export -> import -> export cycle and return both markdown strings // plus the intermediate ProseMirror doc (mirrors the property test's helper). async function roundTrip(node: any): Promise<{ md1: string; doc2: any; md2: string }> { const md1 = convertProseMirrorToMarkdown(doc(node)); const doc2 = await markdownToProseMirror(md1); const md2 = convertProseMirrorToMarkdown(doc2); return { md1, doc2, md2 }; } // --------------------------------------------------------------------------- // 1. pageBreak DATA LOSS (markdown-converter.ts has NO `case "pageBreak"`). // // The schema declares a `pageBreak` block atom (docmost-schema.ts ~L1009), so a // real document CAN legally contain one. The converter's switch has no branch // for it, so it falls through to `default`, which renders only the node's // children — and a pageBreak atom has NONE. It therefore exports to "" and the // node silently disappears: an exported markdown file can never carry a page // break, and a round-trip cannot reconstruct it. We pin this as a known // divergence with an `it.fails` round-trip repro (mirroring the package's two // existing documented `it.fails` bugs in markdown-roundtrip.property.test.ts). // --------------------------------------------------------------------------- describe('pageBreak data loss (no converter case — SPEC §11 divergence)', () => { it('exports a pageBreak node to the schema-matching block div', () => { // FIXED: a standalone pageBreak now emits the block-level HTML div so the // node survives instead of being erased to "". expect(convertProseMirrorToMarkdown(doc({ type: 'pageBreak' }))).toBe( '
', ); }); it('keeps a pageBreak sitting BETWEEN two paragraphs on export', () => { // FIXED: with surrounding content the divider is emitted as its own block // between the two paragraphs (joined by the doc "\n\n"), no longer dropped. const out = convertProseMirrorToMarkdown( doc(para(text('before')), { type: 'pageBreak' }, para(text('after'))), ); expect(out).toBe( 'before\n\n\n\nafter', ); expect(out).toContain('pageBreak'); }); // FIXED: a pageBreak node now survives an export -> import -> export cycle // because the FIRST export emits the schema-matching block div, which marked // passes through and generateJSON rebuilds into a pageBreak node again. it('a pageBreak node round-trips (export -> import yields a pageBreak)', async () => { const { md1, doc2 } = await roundTrip({ type: 'pageBreak' }); expect(md1).not.toBe(''); const types = (doc2.content || []).map((n: any) => n.type); expect(types).toContain('pageBreak'); }); }); // --------------------------------------------------------------------------- // 2. subpages round-trip (`case "subpages"` emits the schema-matching div). // // It used to emit the literal `{{SUBPAGES}}`, which has no markdown/HTML meaning, // so on re-import the subpages BLOCK came back as a plain PARAGRAPH carrying the // literal string (the embed rendered as visible "{{SUBPAGES}}" text on the page // after a sync — data loss). It now emits `h |
|---|
p1
|
m |
h |
|---|
a|b c |

![]() |
a
body
done
todo
lone
, not markdown "> q". it('a blockquote in a column emitsand round-trips', async () => { const { md1, doc2, md2 } = await roundTrip( oneColumn({ type: 'blockquote', content: [para(text('q'))] }), ); expect(md1).toBe( '', ); expect(md2).toBe(md1); expect(colChildOf(doc2)?.type).toBe('blockquote'); }); // 22. horizontalRule inside a column: literalq
, not markdown "---". it('a horizontalRule in a column emits
and round-trips', async () => { const { md1, doc2, md2 } = await roundTrip( oneColumn({ type: 'horizontalRule' }), ); expect(md1).toBe( '', ); expect(md2).toBe(md1); expect(colChildOf(doc2)?.type).toBe('horizontalRule'); }); // 23. Unknown block type with NON-text block children ->-wrap of children. it('an unknown block with block children wraps them in(no markdown leak)', () => { const md1 = convertProseMirrorToMarkdown( doc( oneColumn({ type: 'someFutureBlock', content: [para(text('a')), para(text('b'))], }), ), ); expect(md1).toContain(''); // No markdown paragraph separator survives inside the raw-HTML column. expect(md1).toBe( 'a
b
', ); }); // 24. Unknown block with ONLY inline/text children ->a
b
inlineToHtml. it('an unknown block with only inline children renders inline as HTML (marks not markdown)', () => { const md1 = convertProseMirrorToMarkdown( doc( oneColumn({ type: 'someInlineOnlyBlock', content: [text('hi'), { type: 'text', text: '!', marks: [{ type: 'bold' }] }], }), ), ); expect(md1).toContain('hi!'); }); // 25. mathBlock inside a column delegates through processNode (NOT $$ fence). it('a mathBlock in a column delegates to processNode (HTML div, no $$ fence)', () => { const md1 = convertProseMirrorToMarkdown( doc(oneColumn({ type: 'mathBlock', attrs: { text: 'a^2+b^2' } })), ); expect(md1).toContain( '', ); expect(md1).not.toContain('$$'); }); // 26. SPANNED table inside a column delegates to processNode -> raw. it('a spanned table in a column delegates to raw
HTML (no GFM pipes)', () => { const md1 = convertProseMirrorToMarkdown( doc( oneColumn({ type: 'table', content: [ { type: 'tableRow', content: [ { type: 'tableCell', attrs: { colspan: 2 }, content: [para(text('x'))], }, ], }, ], }), ), ); expect(md1).toContain('
blockChildrenToHtml. it('a list item with paragraph+codeBlock in a column emits both blocks as HTML', () => { const md1 = convertProseMirrorToMarkdown( doc( oneColumn({ type: 'bulletList', content: [ { type: 'listItem', content: [ para(text('p')), { type: 'codeBlock', attrs: { language: 'js' }, content: [text('a\nb')], }, ], }, ], }), ), ); expect(md1).toContain('
p
'); expect(md1).toContain(''); // The two blocks appear sequentially inside the samea\nb. expect(md1).toContain( ' ', ); }); // 28. ordered list item whose 2nd block child is a NESTED bulletList. it('an ordered list item with a nested bulletList in a column emits nested p
a\nbHTML', () => { const md1 = convertProseMirrorToMarkdown( doc( oneColumn({ type: 'orderedList', content: [ { type: 'listItem', content: [ para(text('p1')), { type: 'bulletList', content: [ { type: 'listItem', content: [para(text('nested'))] }, ], }, ], }, ], }), ), ); // NOTE(review): the spec's expected literal said '
', // but blockChildrenToHtml renders the nested listItem's paragraph child as a // real
- nested
, so the actual (correct) emission is '
'. expect(md1).toContain( '
nested
', ); // No markdown list markers leaked into the raw-HTML column. expect(md1).not.toContain('1. '); expect(md1).not.toContain('- nested'); }); // 29. mathInline atom inside a column paragraph -> inlineToHtml delegates via processNode. it('a mathInline atom in a column paragraph emits schema HTML (no $...$ fence)', () => { const md1 = convertProseMirrorToMarkdown( doc(oneColumn(para(text('eq: '), { type: 'mathInline', attrs: { text: 'x_i' } }))), ); expect(md1).toContain( '
p1
nested
eq:
', ); expect(md1).not.toContain('$x_i$'); }); }); // =========================================================================== // 30. heading.textAlign round-trip (A1). The paragraph case already exports a // non-default alignment as a styled `` that re-parses // losslessly; headings used to emit only the bare `## text` form, silently // DROPPING textAlign on export. The heading case is now symmetric: an aligned // heading exports as `
` and re-parses back to a heading // carrying BOTH the level and the textAlign, so the round-trip is lossless; an // UNaligned heading still emits the bare `## text` markdown form (no churn). // =========================================================================== const alignedHeading = (level: number, align: string, ...inline: any[]) => ({ type: 'heading', attrs: { level, textAlign: align }, content: inline, }); describe('heading.textAlign round-trip (A1)', () => { it('an aligned heading exports as (not bare ##)', () => { expect(convertProseMirrorToMarkdown(doc(alignedHeading(2, 'center', text('Title'))))).toBe( ' Title
', ); }); it('survives export -> import -> export losslessly (level AND textAlign preserved)', async () => { const input = alignedHeading(2, 'center', text('Title')); const { md1, doc2, md2 } = await roundTrip(input); // Export direction: a styled, injection-safe via escapeAttr. expect(md1).toBe(' Title
'); // Import direction: re-parses to a heading node with the level AND textAlign // (the rawHTML block flows through marked -> generateJSON, where // the heading parse rule matches and the textAlign global attr reads the // style back). Byte-stable second export closes the loop. const h = doc2.content[0]; expect(h.type).toBe('heading'); expect(h.attrs.level).toBe(2); expect(h.attrs.textAlign).toBe('center'); expect(md2).toBe(md1); // Canonical equality of the re-parsed doc against the original input doc. expect(docsCanonicallyEqual(doc2, doc(input))).toBe(true); }); it('a right-aligned h3 round-trips its level and alignment', async () => { const { doc2 } = await roundTrip(alignedHeading(3, 'right', text('Head'))); const h = doc2.content[0]; expect(h.type).toBe('heading'); expect(h.attrs.level).toBe(3); expect(h.attrs.textAlign).toBe('right'); }); it('an UNaligned heading still emits the bare "## text" form (no HTML churn)', () => { const bare = convertProseMirrorToMarkdown(doc(heading(2, text('Plain')))); expect(bare).toBe('## Plain'); expect(bare).not.toContain('