import { test } from 'node:test'; import assert from 'node:assert/strict'; import { parseMarkdown, toMarkdown, mdToHtml, htmlToMd, toHtmlDocument } from '../src/lib/markdown.js'; const SAMPLE = `Plain paragraph with **bold**, *italic* and \`code\`. Second line of the same paragraph. # Heading one ## Heading two ### Heading three - first bullet - second bullet - [ ] open task - [x] done task > A quote > over two lines > [!note] > A callout with Ctrl K --- \`\`\`ts const a = 1 < 2 && "x" console.log(a) \`\`\`
More Hidden text with **bold**.
$$ x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a} $$ \`\`\`mermaid graph TD; A-->B; \`\`\` `; test('markdown survives a round trip unchanged', () => { const blocks = parseMarkdown(SAMPLE); assert.equal(toMarkdown(blocks), SAMPLE); // and a second pass is stable assert.equal(toMarkdown(parseMarkdown(toMarkdown(blocks))), SAMPLE); }); test('block types are recognised', () => { const types = parseMarkdown(SAMPLE).map((b) => b.type); assert.deepEqual(types, ['p', 'h1', 'h2', 'h3', 'bullet', 'bullet', 'todo', 'todo', 'quote', 'callout', 'divider', 'code', 'toggle', 'math', 'mermaid']); const code = parseMarkdown(SAMPLE).find((b) => b.type === 'code'); assert.equal(code.lang, 'ts'); assert.equal(code.text, 'const a = 1 < 2 && "x"\nconsole.log(a)'); }); test('inline math converts both ways', () => { assert.equal(mdToHtml('a $E=mc^2$ b'), 'a E=mc^2 b'); assert.equal(htmlToMd('a E=mc^2 b'), 'a $E=mc^2$ b'); }); test('inline math converts both ways', () => { assert.equal(mdToHtml('a $E=mc^2$ b'), 'a E=mc^2 b'); assert.equal(htmlToMd('a E=mc^2 b'), 'a $E=mc^2$ b'); }); test('inline math converts both ways', () => { assert.equal(mdToHtml('a $E=mc^2$ b'), 'a E=mc^2 b'); assert.equal(htmlToMd('a E=mc^2 b'), 'a $E=mc^2$ b'); }); test('inline math converts both ways', () => { assert.equal(mdToHtml('a $E=mc^2$ b'), 'a E=mc^2 b'); assert.equal(htmlToMd('a E=mc^2 b'), 'a $E=mc^2$ b'); }); test('inline formatting converts both ways and escapes HTML', () => { assert.equal(mdToHtml('a **b** *c* ``'), 'a b c <d>'); assert.equal(htmlToMd('a b c <d>'), 'a **b** *c* ``'); assert.equal(mdToHtml('5 < 6 & 7'), '5 < 6 & 7'); assert.equal(htmlToMd('line
next word'), 'line\nnext word'); assert.equal(htmlToMd('plain'), 'plain'); }); test('contenteditable output becomes clean markdown', () => { const blocks = [ { type: 'p', html: 'Hello world' }, { type: 'p', html: '' }, { type: 'todo', html: 'Ship it', checked: true }, { type: 'bullet', html: 'one' }, { type: 'h2', html: 'Next' } ]; assert.equal(toMarkdown(blocks), 'Hello **world**\n\n- [x] Ship it\n- one\n\n## Next\n'); }); test('empty and unusual input', () => { assert.equal(parseMarkdown('').length, 1); assert.equal(toMarkdown(parseMarkdown('')), ''); const b = parseMarkdown('#### deep heading\n* star bullet\n+ plus bullet'); assert.deepEqual(b.map((x) => x.type), ['h3', 'bullet', 'bullet']); }); test('page links become chips and come back unchanged', () => { const md = 'See [[Roadmap]], [[Projects/Plan|the plan]] and `[[not a link]]`.'; const html = mdToHtml(md); assert.match(html, /Roadmap<\/span>/); assert.match(html, /data-ref="Projects\/Plan">the plan<\/span>/); assert.match(html, /\[\[not a link\]\]<\/code>/); assert.equal(htmlToMd(html), md); assert.equal(htmlToMd(mdToHtml('[[A_b_c]] & [[xPlan '), '[[Plan]]'); assert.equal(htmlToMd('Plan and more'), '[[Plan]] and more'); }); test('images are their own block and round trip', () => { const md = '![](pasted-1.png)\n\n![A cat](my%20cat.jpg)\n\n![[photo.webp]]\n\nText with ![inline](x.png) stays text.\n'; const blocks = parseMarkdown(md); assert.deepEqual(blocks.map((b) => b.type), ['image', 'image', 'image', 'p']); assert.equal(blocks[1].src, 'my cat.jpg'); assert.equal(blocks[1].alt, 'A cat'); assert.equal(blocks[2].wiki, true); assert.equal(toMarkdown(blocks), md); }); test('numbered lists count per run and strikethrough round trips', () => { const md = '1. one\n2. two\n\nBreak\n\n1. again\n- bullet\n1. new run\n\n~~gone~~ and **kept**\n'; const blocks = parseMarkdown(md); assert.deepEqual(blocks.map((b) => b.type), ['number', 'number', 'p', 'number', 'bullet', 'number', 'p']); assert.equal(toMarkdown(blocks), md); // numbers in the file are rewritten to count from 1 assert.equal(toMarkdown(parseMarkdown('3. a\n7) b\n')), '1. a\n2. b\n'); assert.equal(htmlToMd('a b c'), '~~a~~ ~~b~~ ~~c~~'); assert.equal(mdToHtml('~~x~~'), 'x'); }); test('web links and bare URLs round trip; unsafe ones stay text', () => { const md = 'Read [the **docs**](https://example.com/a_b "Guide") or https://ink.app/x_(y). Mail [me](mailto:a@b.c), [spaced]().'; const html = mdToHtml(md); assert.match(html, /the docs<\/b><\/a>/); assert.match(html, /href="https:\/\/ink\.app\/x_\(y\)" spellcheck="false" data-auto="1">https:\/\/ink\.app\/x_\(y\)<\/a>\./); assert.equal(htmlToMd(html), md); for (const keep of ['[x](javascript:alert(1))', '[rel](notes.md)', 'plain http:// text']) assert.equal(htmlToMd(mdToHtml(keep)), keep); assert.ok(!mdToHtml('[x](javascript:alert(1))').includes('Ink'), '[Ink](https://ink.app)'); }); test('tables keep their source until edited, then line up', () => { const md = '|a|b|\n|:-|-:|\n|`x|y`|2|\n| p \\| q | [[Plan]] |\n'; const [t] = parseMarkdown(md); assert.equal(t.type, 'table'); assert.deepEqual(t.align, ['left', 'right']); assert.equal(t.rows.length, 3); assert.equal(t.rows[1][0], 'x|y'); assert.equal(t.rows[2][0], 'p | q'); assert.equal(toMarkdown([t]), md); delete t.src; t.rows[1][1] = '20'; assert.equal(toMarkdown([t]), '| a | b |\n| :----- | -------: |\n| `x\\|y` | **20** |\n| p \\| q | [[Plan]] |\n'); assert.equal(parseMarkdown('a | b\nno delimiter row')[0].type, 'p'); }); test('==highlight== round trips; == and === in normal text are left alone', () => { for (const md of ['A ==bright== idea', '==**bold** inside== and ==*it*==', 'if a == b then', 'x === y === z', '`==code==` stays']) { assert.equal(htmlToMd(mdToHtml(md)), md); } assert.equal(mdToHtml('A ==bright== idea'), 'A bright idea'); assert.ok(!mdToHtml('if a == b == c').includes('')); assert.ok(!mdToHtml('`==code==`').includes('')); }); test('footnotes: references, inline notes and footnote texts round trip; export numbers them in reading order', () => { const md = 'A claim[^src] with an aside^[said in passing].\n\nAnother[^2] and again[^src].\n\n[^2]: Second note.\n[^src]: The source,\n on two lines.\n[^unused]: Nobody points here.\n'; const blocks = parseMarkdown(md); assert.deepEqual(blocks.map((b) => b.type + (b.fid ? ':' + b.fid : '')), ['p', 'p', 'footnote:2', 'footnote:src', 'footnote:unused']); assert.match(blocks[0].html, //); assert.match(blocks[0].html, /data-inline="said in passing"/); assert.equal(blocks[3].html, 'The source,\non two lines.'); assert.equal(toMarkdown(blocks), md); const html = toHtmlDocument('t', blocks); assert.deepEqual([...html.matchAll(/]*>(?:)?(\d+)/g)].map((m) => m[2] + (m[1] ? ':' + m[1] : '')), ['1:src', '2', '3:2', '1:src']); assert.match(html, /

3<\/sup> Second note\.<\/p>/); assert.match(html, /

unused<\/sup>/); // "[^1]:" mid-line isn't a footnote text, and ids can't hold spaces assert.deepEqual(parseMarkdown('Text [^1]: not a definition\n\n[^a b]: neither\n').map((b) => b.type), ['p', 'p']); }); test('columns: fenced :::: columns round-trip, with any blocks in a column', () => { const src = 'Before\n\n:::: columns\n::: column\nLeft **bold**\n\n- a\n- b\n:::\n::: column\n## Right\n\ntext\n:::\n::::\n\nAfter\n'; const blocks = parseMarkdown(src); assert.deepEqual(blocks.map((b) => b.type + (b.col ? `[${b.col.i}]` : '')), ['p', 'p[0]', 'bullet[0]', 'bullet[0]', 'h2[1]', 'p[1]', 'p']); const g = blocks[1].col.g; assert.ok(blocks.slice(1, 6).every((b) => b.col.g === g), 'one group'); assert.equal(toMarkdown(blocks), src); // three columns, one of them empty: it keeps its place const three = ':::: columns\n::: column\na\n:::\n::: column\n:::\n::: column\nc\n:::\n::::\n'; const b3 = parseMarkdown(three); assert.deepEqual(b3.map((b) => b.col.i), [0, 1, 2]); // not closed: just text, nothing lost const open = ':::: columns\n::: column\nlost?\n:::\n'; assert.ok(toMarkdown(parseMarkdown(open)).includes('lost?')); // columns inside a code block are code const code = '```\n:::: columns\n::: column\nx\n:::\n::::\n```\n'; assert.deepEqual(parseMarkdown(code).map((b) => b.type), ['code']); // two groups in a row stay two groups const two = ':::: columns\n::: column\na\n:::\n::::\n\n:::: columns\n::: column\nb\n:::\n::::\n'; const gs = new Set(parseMarkdown(two).map((b) => b.col.g)); assert.equal(gs.size, 2); });