import { describe, expect, it } from 'vitest'; import { docToMarkdown, markdownToDoc } from './markdown'; /** * Markdown → doc → Markdown must be stable for the node/mark set the * schema supports (issue #24 acceptance criterion). Each fixture is * expected to serialize back to itself byte-for-byte. */ const FIXTURES: Record = { headings: '# Level one\n\n## Level two\n\n### Level three\n\n#### Level four', paragraphInlineMarks: 'Plain, **bold**, *italic*, ~~strikethrough~~, `code`, and [a link](https://example.org/page).', bulletList: '- First item\n- Second item\n- Third item', orderedList: '1. First\n2. Second\n3. Third', taskList: '- [ ] Buy milk\n- [x] Walk the dog', blockquote: '> Quoted paragraph one.\n>\n> Quoted paragraph two.', codeBlock: '```\nconst x = 1;\nconsole.log(x);\n```', horizontalRule: 'Above\n\n---\n\nBelow', table: '| Name | Role |\n| --- | --- |\n| Uma | Editor |\n| Otto | Reader |', image: '![A pond](file-123)', nestedBlockquoteAndList: '> - Item inside a quote\n> - Second item', wikilinkBare: 'See [[architecture]] for details.', wikilinkWithDisplay: 'See [[architecture|the design docs]] for details.', }; describe('markdown round-trip (issue #24)', () => { for (const [name, markdown] of Object.entries(FIXTURES)) { it(`stabilizes: ${name}`, () => { const doc = markdownToDoc(markdown); expect(docToMarkdown(doc)).toBe(markdown); }); } it('produces the expected document shape for a mixed fixture', () => { const doc = markdownToDoc(FIXTURES.paragraphInlineMarks ?? ''); expect(doc.toJSON()).toMatchObject({ type: 'doc', content: [{ type: 'paragraph' }], }); }); it('clamps markdown heading levels beyond 4 down to level 4', () => { const doc = markdownToDoc('###### Deep heading'); const heading = doc.firstChild; expect(heading?.type.name).toBe('heading'); expect(heading?.attrs.level).toBe(4); }); it('does not turn a mixed checkbox/plain list into a task list', () => { const doc = markdownToDoc('- [ ] Task\n- Plain item'); expect(doc.firstChild?.type.name).toBe('bullet_list'); }); it('parses a wikilink into a node with slug and optional display text (issue #46)', () => { const withDisplay = markdownToDoc('[[my-page|My Page]]').firstChild?.firstChild; expect(withDisplay?.type.name).toBe('wikilink'); expect(withDisplay?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: 'My Page' }); const bare = markdownToDoc('[[my-page]]').firstChild?.firstChild; expect(bare?.attrs).toMatchObject({ targetSlug: 'my-page', displayText: null }); }); it('leaves malformed brackets as plain text', () => { // A single bracket pair is an ordinary (broken) markdown link, not a wikilink. const doc = markdownToDoc('[[unclosed and [not a link]'); expect(doc.textContent).toContain('[[unclosed'); let hasWikilink = false; doc.descendants((n) => { if (n.type.name === 'wikilink') hasWikilink = true; }); expect(hasWikilink).toBe(false); }); it('round-trips a section-style container as a fenced div (issue #75)', () => { const md = [ '::: {data-section-style="section-styles-basic/callout"}', 'Inside the callout.', '', 'Second paragraph.', ':::', ].join('\n'); const doc = markdownToDoc(md); const section = doc.firstChild; expect(section?.type.name).toBe('section'); expect(section?.attrs).toMatchObject({ pluginId: 'section-styles-basic', styleId: 'callout', }); expect(section?.childCount).toBe(2); // two paragraphs preserved // Serialization is idempotent: a second pass reproduces the first exactly. const once = docToMarkdown(doc); expect(docToMarkdown(markdownToDoc(once))).toBe(once); expect(once).toContain('data-section-style="section-styles-basic/callout"'); }); it('handles nested section containers (issue #75)', () => { const md = [ '::: {data-section-style="p/outer"}', '::: {data-section-style="p/inner"}', 'Deep.', ':::', ':::', ].join('\n'); const outer = markdownToDoc(md).firstChild; expect(outer?.attrs.styleId).toBe('outer'); expect(outer?.firstChild?.type.name).toBe('section'); expect(outer?.firstChild?.attrs.styleId).toBe('inner'); }); it('round-trips a plugin block as a reserved fence (issue #76)', () => { const md = ['```dorfteich-plugin mermaid/diagram', '{"source":"graph TD; A-->B"}', '```'].join( '\n', ); const doc = markdownToDoc(md); const block = doc.firstChild; expect(block?.type.name).toBe('plugin_block'); expect(block?.attrs).toMatchObject({ pluginId: 'mermaid', blockType: 'diagram', data: { source: 'graph TD; A-->B' }, }); const once = docToMarkdown(doc); expect(docToMarkdown(markdownToDoc(once))).toBe(once); expect(once).toContain('```dorfteich-plugin mermaid/diagram'); }); it('keeps an ordinary fenced code block untouched by the plugin rule', () => { const doc = markdownToDoc('```js\nconst x = 1;\n```'); expect(doc.firstChild?.type.name).toBe('code_block'); }); it('degrades an unparsable plugin-block body to empty data (issue #76)', () => { const doc = markdownToDoc('```dorfteich-plugin p/t\nnot json at all\n```'); expect(doc.firstChild?.type.name).toBe('plugin_block'); expect(doc.firstChild?.attrs.data).toEqual({}); }); it('pads a colspan cell so every row keeps the column count (issue #337)', () => { // Built directly: markdown cannot express merged cells, so the export // is lossy by format — but it must stay a well-formed GFM table. const schema = markdownToDoc('x').type.schema; const cell = (type: string, text: string, attrs: { colspan?: number } | null = null) => schema.node(type, attrs, [schema.node('paragraph', null, [schema.text(text)])]); const doc = schema.node('doc', null, [ schema.node('table', null, [ schema.node('table_row', null, [cell('table_header', 'A'), cell('table_header', 'B')]), schema.node('table_row', null, [cell('table_cell', 'wide', { colspan: 2 })]), ]), ]); expect(docToMarkdown(doc)).toBe('| A | B |\n| --- | --- |\n| wide | |'); }); it('escalates the fence when the data contains backticks (issue #76)', () => { const doc = markdownToDoc('```dorfteich-plugin p/t\n{"code":"x"}\n```'); const withTicks = doc.type.schema.nodes.plugin_block!.create({ pluginId: 'p', blockType: 't', data: { code: 'a ``` fence inside' }, }); const md = docToMarkdown(doc.type.schema.nodes.doc!.create(null, [withTicks])); const reparsed = markdownToDoc(md); expect(reparsed.firstChild?.type.name).toBe('plugin_block'); expect(reparsed.firstChild?.attrs.data).toEqual({ code: 'a ``` fence inside' }); }); });