import { describe, expect, it } from 'vitest'; // Import DIRECTLY from src (matching the other converter unit tests), not the // docmost-client barrel. import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js'; import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js'; // --------------------------------------------------------------------------- // #293 canon #4: images ALWAYS serialize as `![alt](src)`. Non-default attrs // ride along in an attached `` comment on the SAME line, which the // importer materializes back onto the before generateJSON drops it. An // attr equal to the schema default is NOT emitted. The image align default is // unified to "center" (matching editor-ext), so bare/center images stay clean // and only a genuinely non-default alignment (left/right) emits a comment. // // In raw-HTML contexts (inside a column / spanned cell) the prior `` form // is kept; comments are dropped by the DOM parse stage there. // --------------------------------------------------------------------------- const doc = (...nodes: any[]) => ({ type: 'doc', content: nodes }); const image = (attrs: any) => doc({ type: 'image', attrs: { src: '/i.png', ...attrs } }); // Find the first image node anywhere in a PM JSON doc. function findImage(node: any): any | null { if (!node || typeof node !== 'object') return null; if (node.type === 'image') return node; if (Array.isArray(node.content)) { for (const child of node.content) { const hit = findImage(child); if (hit) return hit; } } return null; } // Export a doc, re-import it, and hand back the markdown + re-imported image. async function roundTrip(source: any): Promise<{ md: string; img: any; back: any }> { const md = convertProseMirrorToMarkdown(source); const back = await markdownToProseMirror(md); return { md, img: findImage(back), back }; } describe('#293 canon #4 — image serialization + attached img-comment', () => { it('a bare image (src only) emits `![]()` with NO comment and round-trips', async () => { const { md, img } = await roundTrip(image({})); expect(md).toBe('![](/i.png)'); expect(md).not.toContain(''); expect(img.attrs.align).toBe('left'); }); it('align "right" emits an img-comment and round-trips', async () => { const { md, img } = await roundTrip(image({ align: 'right' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.align).toBe('right'); }); it('width alone emits a single-key comment and round-trips', async () => { const { md, img } = await roundTrip(image({ width: '420' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.width).toBe('420'); }); it('height alone emits a single-key comment and round-trips', async () => { const { md, img } = await roundTrip(image({ height: '300' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.height).toBe('300'); }); it('size alone emits a single-key comment and round-trips', async () => { const { md, img } = await roundTrip(image({ size: '48' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.size).toBe('48'); }); it('aspectRatio alone emits a single-key comment and round-trips', async () => { const { md, img } = await roundTrip(image({ aspectRatio: '1.777' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.aspectRatio).toBe('1.777'); }); it('attachmentId (the file link — data-loss critical) rides in the comment and round-trips', async () => { const { md, img } = await roundTrip(image({ attachmentId: 'att-777' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.attachmentId).toBe('att-777'); }); it('caption rides in the comment and round-trips', async () => { const { md, img } = await roundTrip(image({ caption: 'Рис. 1' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.caption).toBe('Рис. 1'); }); it('title rides in the comment and round-trips', async () => { const { md, img } = await roundTrip(image({ title: 'a tooltip' })); expect(md).toBe('![](/i.png) '); expect(img.attrs.title).toBe('a tooltip'); }); it('multiple attrs at once appear in the stable key order and round-trip', async () => { const { md, img } = await roundTrip( image({ alt: 'схема', width: '420', height: '300', align: 'left', size: '48', aspectRatio: '1.5', attachmentId: 'att-1', caption: 'Рис. 1', title: 'tip', }), ); // Stable order: width, height, align, size, aspectRatio, attachmentId, caption, title. expect(md).toBe( '![схема](/i.png) ', ); expect(img.attrs.width).toBe('420'); expect(img.attrs.height).toBe('300'); expect(img.attrs.align).toBe('left'); expect(img.attrs.size).toBe('48'); expect(img.attrs.aspectRatio).toBe('1.5'); expect(img.attrs.attachmentId).toBe('att-1'); expect(img.attrs.caption).toBe('Рис. 1'); expect(img.attrs.title).toBe('tip'); }); // MANDATORY (#293 canon #4): a caption containing the comment-closing `-->` // must be encoded so it can never break the HTML comment; JSON.parse restores // it byte-exact on import. it('a caption containing `-->` is escaped, does not break the comment, and round-trips byte-exact', async () => { const caption = 'see --> here'; const { md, img } = await roundTrip(image({ caption })); // The `--` pair is defused as the JSON unicode escape, so the literal // caption text is NOT present verbatim and the comment cannot close early. expect(md).toContain('\\u002d\\u002d'); expect(md).not.toContain('see --> here'); // The comment still closes exactly once, at the very end. expect(md.endsWith('-->')).toBe(true); // Restored byte-exact on re-import. expect(img.attrs.caption).toBe('see --> here'); }); // A whole raw comment-closer as the caption is the adversarial edge. it('a caption that IS `-->` round-trips byte-exact', async () => { const { img } = await roundTrip(image({ caption: '-->' })); expect(img.attrs.caption).toBe('-->'); }); it('an image INSIDE a column keeps the raw form (no img-comment) and round-trips', async () => { const source = doc({ type: 'columns', attrs: { layout: 'two' }, content: [ { type: 'column', content: [ { type: 'image', attrs: { src: '/i.png', alt: 'c', width: '320', align: 'left', attachmentId: 'att-9' }, }, ], }, { type: 'column', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'r' }] }] }, ], }); const md = convertProseMirrorToMarkdown(source); expect(md).toContain(' { const back = await markdownToProseMirror('![](/i.png) '); const img = findImage(back); expect(img).toBeTruthy(); expect(img.attrs.width).toBeNull(); expect(img.attrs.align).toBe('center'); // default }); it('a STANDALONE img-comment (no adjacent ) is inert — no image materialized', async () => { const back = await markdownToProseMirror(''); expect(findImage(back)).toBeNull(); }); it('unknown keys in a valid img-comment are ignored; the image is otherwise default', async () => { const back = await markdownToProseMirror('![](/i.png) '); const img = findImage(back); expect(img).toBeTruthy(); expect(img.attrs.width).toBeNull(); expect(img.attrs.align).toBe('center'); expect((img.attrs as any).zzz).toBeUndefined(); }); it('NUMERIC sizing attrs serialize as strings and round-trip byte-stably', () => { // The import side reads DOM attributes back as strings, so a numeric source // value must be stringified in the payload or the first round-trip churns // `420 -> "420"` (a spurious one-time git diff). Assert the emitted string // form AND that a second export is byte-identical to the first. const d = image({ width: 420, height: 200, size: 80, aspectRatio: 1.5 }); const md1 = convertProseMirrorToMarkdown(d); expect(md1).toBe( '![](/i.png) ', ); return markdownToProseMirror(md1).then((back) => { const md2 = convertProseMirrorToMarkdown(back); expect(md2).toBe(md1); // byte-stable: no 420 -> "420" churn }); }); });