import { describe, expect, it } from 'vitest'; // Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in // collaboration.ts and mutates global DOM at import time). import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js'; import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js'; import { attachedCommentFor, parseAttachedComment, } from '../src/lib/attached-comment.js'; // #293 canon decision #9: paragraph/heading `textAlign` serializes as an // ATTACHED HTML comment at the END of the block line — // `some text ` // — replacing the old `
` / `

` wrappers, which // did NOT round-trip cleanly (alignment was lost on the first stabilize pass). // These tests are non-vacuous: they assert the EXACT emitted markdown (so they // fail against any wrapper form) AND that the alignment survives a full // PM -> MD -> PM round trip (which the old `

` never did). const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes }); const text = (t: string) => ({ type: 'text', text: t }); const para = (align: string | null, ...inline: any[]) => ({ type: 'paragraph', attrs: align === null ? {} : { textAlign: align }, content: inline, }); const heading = (level: number, align: string | null, ...inline: any[]) => ({ type: 'heading', attrs: align === null ? { level } : { level, textAlign: align }, content: inline, }); // Find the first paragraph/heading node in a generated doc (skips the doc root). const firstBlock = (d: any) => d.content?.[0]; describe('attached-comment primitives (reusable for #9/#4/#8)', () => { it('attachedCommentFor emits a compact ``', () => { expect(attachedCommentFor('attrs', { textAlign: 'center' })).toBe( '', ); }); it('attachedCommentFor escapes a `--` pair so it cannot close the comment early', () => { // A string value containing `--` would otherwise inject `-->`. Each hyphen of // the pair is emitted as the JSON unicode escape -; JSON.parse restores // the original hyphens on the reading side. const s = attachedCommentFor('img', { alt: 'a--b' }); expect(s).toBe(''); // No premature `--` inside the payload (between the `` closer), so the comment cannot terminate early. expect(s.slice(4, -3)).not.toContain('--'); // Round-trip through the parser primitive restores the exact value. const inner = s.slice(''.length); expect(parseAttachedComment(inner)).toEqual({ name: 'img', attrs: { alt: 'a--b' } }); }); it('parseAttachedComment fails open on malformed JSON and non-objects', () => { expect(parseAttachedComment('attrs {not json}')).toBeNull(); expect(parseAttachedComment('attrs [1,2]')).toBeNull(); expect(parseAttachedComment(' ')).toBeNull(); // Name-only comment is a valid marker with empty attrs. expect(parseAttachedComment('attrs')).toEqual({ name: 'attrs', attrs: {} }); }); }); describe('paragraph.textAlign serialization (#293 #9)', () => { for (const align of ['center', 'right', 'justify']) { it(`paragraph textAlign "${align}" -> trailing comment`, () => { expect(convertProseMirrorToMarkdown(doc(para(align, text('hello'))))).toBe( `hello `, ); }); } it('default textAlign (null) emits NO comment', () => { expect(convertProseMirrorToMarkdown(doc(para(null, text('hello'))))).toBe('hello'); }); it('"left" (visual default) emits NO comment', () => { expect(convertProseMirrorToMarkdown(doc(para('left', text('hello'))))).toBe('hello'); }); }); describe('heading.textAlign serialization (#293 #9)', () => { it('heading keeps "## text" and attaches the alignment comment', () => { expect(convertProseMirrorToMarkdown(doc(heading(2, 'center', text('Title'))))).toBe( '## Title ', ); }); it('default heading emits the bare "## text" form', () => { expect(convertProseMirrorToMarkdown(doc(heading(3, null, text('Plain'))))).toBe('### Plain'); }); }); describe('paragraph.textAlign round-trip PM -> MD -> PM (#293 #9)', () => { for (const align of ['center', 'right', 'justify']) { it(`preserves paragraph textAlign "${align}"`, async () => { const md1 = convertProseMirrorToMarkdown(doc(para(align, text('hello')))); expect(md1).toBe(`hello `); const doc2 = await markdownToProseMirror(md1); const block = firstBlock(doc2); expect(block.type).toBe('paragraph'); expect(block.attrs.textAlign).toBe(align); // Text is intact (the trailing space before the comment is trimmed). expect(block.content?.[0]?.text).toBe('hello'); // Byte-stable second export closes the loop. expect(convertProseMirrorToMarkdown(doc2)).toBe(md1); }); } it('default paragraph re-imports with textAlign null (no comment survives)', async () => { const md1 = convertProseMirrorToMarkdown(doc(para(null, text('hello')))); const doc2 = await markdownToProseMirror(md1); const block = firstBlock(doc2); expect(block.type).toBe('paragraph'); expect(block.attrs.textAlign ?? null).toBeNull(); }); }); describe('heading.textAlign round-trip PM -> MD -> PM (#293 #9)', () => { for (const [level, align] of [ [2, 'center'], [3, 'right'], [1, 'justify'], ] as [number, string][]) { it(`preserves h${level} textAlign "${align}"`, async () => { const md1 = convertProseMirrorToMarkdown(doc(heading(level, align, text('Head')))); expect(md1).toBe(`${'#'.repeat(level)} Head `); const doc2 = await markdownToProseMirror(md1); const block = firstBlock(doc2); expect(block.type).toBe('heading'); expect(block.attrs.level).toBe(level); expect(block.attrs.textAlign).toBe(align); expect(convertProseMirrorToMarkdown(doc2)).toBe(md1); }); } }); describe('attached-comment fail-open in the import pipeline (#293 #9)', () => { it('a malformed attrs comment is ignored (default attrs kept)', async () => { const doc2 = await markdownToProseMirror('hello '); const block = firstBlock(doc2); expect(block.type).toBe('paragraph'); expect(block.attrs.textAlign ?? null).toBeNull(); expect(block.content?.[0]?.text).toBe('hello'); }); it('an unknown key in a valid attrs comment is ignored, no comment leaks', async () => { const doc2 = await markdownToProseMirror('hello '); const block = firstBlock(doc2); expect(block.type).toBe('paragraph'); expect(block.attrs.textAlign ?? null).toBeNull(); // The unknown key and the comment marker must not survive into the body. expect(block.content?.[0]?.text).toBe('hello'); const serialized = JSON.stringify(doc2); expect(serialized).not.toContain('bogus'); expect(serialized).not.toContain('