Files
ink-note-app/test/markdown.test.js
T
2026-10-15 11:57:09 +02:00

223 lines
9.9 KiB
JavaScript

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 <kbd>Ctrl</kbd> <kbd>K</kbd>
---
\`\`\`ts
const a = 1 < 2 && "x"
console.log(a)
\`\`\`
<details>
<summary>More</summary>
Hidden text with **bold**.
</details>
$$
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 <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline math converts both ways', () => {
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline math converts both ways', () => {
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline math converts both ways', () => {
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline formatting converts both ways and escapes HTML', () => {
assert.equal(mdToHtml('a **b** *c* `<d>`'), 'a <b>b</b> <i>c</i> <code>&lt;d&gt;</code>');
assert.equal(htmlToMd('a <b>b</b> <i>c</i> <code>&lt;d&gt;</code>'), 'a **b** *c* `<d>`');
assert.equal(mdToHtml('5 < 6 & 7'), '5 &lt; 6 &amp; 7');
assert.equal(htmlToMd('line<br>next&nbsp;word'), 'line\nnext word');
assert.equal(htmlToMd('<span style="x">plain</span>'), 'plain');
});
test('contenteditable output becomes clean markdown', () => {
const blocks = [
{ type: 'p', html: 'Hello <strong>world</strong>' },
{ 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, /<span class="wl" contenteditable="false" data-ref="Roadmap">Roadmap<\/span>/);
assert.match(html, /data-ref="Projects\/Plan">the plan<\/span>/);
assert.match(html, /<code>\[\[not a link\]\]<\/code>/);
assert.equal(htmlToMd(html), md);
assert.equal(htmlToMd(mdToHtml('[[A_b_c]] & [[x<y]]')), '[[A_b_c]] & [[x<y]]');
// a chip whose label was left alone keeps the short form
assert.equal(htmlToMd('<span class="wl missing" contenteditable="false" data-ref="Plan">Plan</span>&nbsp;'), '[[Plan]]');
assert.equal(htmlToMd('<span class="wl" contenteditable="false" data-ref="Plan">Plan</span>&nbsp;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('<strike>a</strike> <del>b</del> <s>c</s>'), '~~a~~ ~~b~~ ~~c~~');
assert.equal(mdToHtml('~~x~~'), '<s>x</s>');
});
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](<https://x.org/a b>).';
const html = mdToHtml(md);
assert.match(html, /<a class="xl" href="https:\/\/example\.com\/a_b" spellcheck="false" data-title="Guide">the <b>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('<a'));
// a link whose text was edited is written as [text](url)
assert.equal(htmlToMd('<a class="xl" href="https://ink.app" data-auto="1">Ink</a>'), '[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], '<code>x|y</code>');
assert.equal(t.rows[2][0], 'p | q');
assert.equal(toMarkdown([t]), md);
delete t.src;
t.rows[1][1] = '<b>20</b>';
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 <mark>bright</mark> idea');
assert.ok(!mdToHtml('if a == b == c').includes('<mark>'));
assert.ok(!mdToHtml('`==code==`').includes('<mark>'));
});
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, /<sup class="fn" contenteditable="false" data-fn="src">/);
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(/<sup class="fn"[^>]*>(?:<a href="#fn-(\w+)">)?(\d+)/g)].map((m) => m[2] + (m[1] ? ':' + m[1] : '')), ['1:src', '2', '3:2', '1:src']);
assert.match(html, /<p class="footnote" id="fn-2"><sup>3<\/sup> Second note\.<\/p>/);
assert.match(html, /<p class="footnote" id="fn-unused"><sup>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);
});