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 (see markdown-converter-gaps.test.ts). import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js'; // Minimal ProseMirror builders. 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, marks?: any[]) => marks ? { type: 'text', text: t, marks } : { type: 'text', text: t }; const para = (...inline: any[]) => ({ type: 'paragraph', content: inline }); // A columns node carrying a SINGLE column, whose content is the supplied block // children. columns/column are raw-HTML containers, so their children render via // blockToHtml -> inlineToHtml (the HTML-mirroring path under test). const oneColumn = (...blocks: any[]) => ({ type: 'columns', attrs: { layout: 'two' }, content: [{ type: 'column', content: blocks }], }); // Extract the inner HTML of the single column from a rendered columns string. // Output shape is: //
, strike->, underline->. This
// is a DISTINCT branch from the top-level mark path; if it leaked markdown, the
// literal ** / `` would survive as text on re-import.
// ---------------------------------------------------------------------------
describe('inlineToHtml: bold/italic/code/strike/underline -> HTML inside columns', () => {
it('mirrors each single-mark run to its schema HTML tag (not markdown markers)', () => {
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
para(
text('b', [{ type: 'bold' }]),
text('i', [{ type: 'italic' }]),
text('c', [{ type: 'code' }]),
text('s', [{ type: 'strike' }]),
text('u', [{ type: 'underline' }]),
),
),
),
);
expect(out).toBe(
'' +
'' +
'bicsu
' +
'',
);
// Belt-and-suspenders: none of the top-level markdown markers leaked.
expect(out).not.toContain('**');
expect(out).not.toContain('~~');
expect(out).not.toContain('`');
});
});
// ---------------------------------------------------------------------------
// 2. inlineToHtml: link/hardBreak/highlight/textStyle/comment inside columns.
//
// Exercises the remaining inlineToHtml branches that are uncovered inside a
// raw-HTML container: link href escaping via escapeAttr (line 621; & -> &,
// " -> "), hardBreak ->
(line 591), highlight WITH vs WITHOUT color
// (624-626), textStyle color (628-630), and comment with data-resolved (632-638).
// ---------------------------------------------------------------------------
describe('inlineToHtml: link/hardBreak/highlight/textStyle/comment inside columns', () => {
it('escapes link hrefs, emits
, plain/colored , span color, and resolved comment', () => {
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
para(
text('lnk', [{ type: 'link', attrs: { href: 'http://a?b&c"d' } }]),
{ type: 'hardBreak' },
text('hl', [{ type: 'highlight', attrs: { color: '#ff0000' } }]),
text('plain', [{ type: 'highlight' }]),
text('clr', [{ type: 'textStyle', attrs: { color: 'red' } }]),
text('cm', [
{ type: 'comment', attrs: { commentId: 'c1', resolved: true } },
]),
),
),
),
);
expect(columnInner(out)).toBe(
'' +
'lnk' +
'
' +
'hl' +
'plain' +
'clr' +
'cm' +
'
',
);
});
it('omits data-resolved when the comment is not resolved', () => {
// The resolved sub-branch (632-638) is load-bearing: an unresolved comment
// must emit a bare data-comment-id span with NO data-resolved attribute.
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
para(
text('cm', [
{ type: 'comment', attrs: { commentId: 'c1', resolved: false } },
]),
),
),
),
);
expect(columnInner(out)).toBe('cm
');
expect(out).not.toContain('data-resolved');
});
});
// ---------------------------------------------------------------------------
// 3. blockToHtml non-paragraph branches inside columns: heading / codeBlock /
// bulletList.
//
// heading -> (718-721), codeBlock with-language vs no-language class fork
// (730-742; the no-language `cls = ''` branch at 741 yields a BARE with
// no class), and bulletList -> ...
(722-725). Code text
// is element TEXT content, so it is escapeHtmlText-escaped (not the attr escaper),
// and embedded newlines are preserved verbatim.
// ---------------------------------------------------------------------------
describe('blockToHtml: heading / codeBlock(lang & no-lang) / bulletList inside columns', () => {
it('emits , language vs bare , and ..
', () => {
const out = convertProseMirrorToMarkdown(
doc(
oneColumn(
{ type: 'heading', attrs: { level: 2 }, content: [text('H')] },
{
type: 'codeBlock',
attrs: { language: 'js' },
content: [text('a\nb')],
},
{ type: 'codeBlock', content: [text('plain')] },
{
type: 'bulletList',
content: [
{ type: 'listItem', content: [para(text('item'))] },
],
},
),
),
);
expect(columnInner(out)).toBe(
'H
' +
'
a\nb
' +
'plain
' +
'item
',
);
// The no-language codeBlock must NOT carry a class attribute (the cls=''
// fork at line 741): its opens bare.
expect(out).toContain('plain
');
});
});
// ---------------------------------------------------------------------------
// 4. Spanned-table renderHtmlCell + orderedList block child (HTML fallback).
//
// A colspan>1 cell forces the WHOLE table to the raw- HTML fallback
// (markdown-converter.ts ~287-331). renderHtmlCell emits colspan + align attrs
// (312-316) and renders each block child via blockToHtml. An orderedList child
// hits the blockToHtml orderedList branch (726-729), which emits
// ..
..
— the schema's `start` attr is NOT emitted by
// this HTML branch.
// ---------------------------------------------------------------------------
describe('spanned table: renderHtmlCell colspan/align + orderedList block child', () => {
it('renders the colspan/align cell with an (start attr is dropped)', () => {
const out = convertProseMirrorToMarkdown(
doc({
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: { colspan: 2, align: 'center' },
content: [
{
type: 'orderedList',
attrs: { start: 3 },
content: [
{ type: 'listItem', content: [para(text('one'))] },
{ type: 'listItem', content: [para(text('two'))] },
],
},
],
},
],
},
],
}),
);
expect(out).toBe(
'' +
'' +
'one
two
' +
' ' +
'
',
);
// The HTML branch does not propagate the ProseMirror `start` attribute.
expect(out).not.toContain('start');
});
});