const assert = require('node:assert'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const lib = {}; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/js/simplex-lib.jsc'), 'utf8'), lib); const { escapeHtml, renderMarkdown } = lib; const plain = (text) => ({ text }); const markup = (type, text, fields) => ({ format: { type: 'markup', token: { type, ...fields } }, text }); const lineBreak = markup('lineBreak', '\n'); const hardBreak = markup('hardBreak', '\n'); const paragraphBreak = (lineBreaks, text = '\n'.repeat(lineBreaks)) => markup('paragraphBreak', text, { lineBreaks }); const headingMark = markup('headingMark', '# ', { level: 1 }); const listMark = (text) => markup('listMark', text); const quoteMark = (text) => markup('quoteMark', text); const fence = markup('fenceMark', '```'); const range = (from, to) => ({ from, to }); const paragraph = (from, to) => ({ type: 'paragraph', content: range(from, to) }); const heading = (from, to) => ({ type: 'heading', level: 1, content: range(from, to) }); const entry = (from, to, items = []) => ({ content: range(from, to), items }); const list = (...items) => ({ type: 'list', items }); const br = (lines) => '
'.repeat(lines); const div = (html) => `
${html}
`; const h1 = (html) => `
${html}
`; const li = (html, level = 0) => `
•${html}
`; const check = (fts, blocks, html, text = '') => assert.strictEqual(renderMarkdown(fts, blocks, text), html); test('each empty line between blocks is one line', () => { const blocks = [heading(1, 2), paragraph(3, 4)]; check([headingMark, plain('a'), lineBreak, plain('b')], blocks, h1('a') + 'b'); check([headingMark, plain('a'), paragraphBreak(2), plain('b')], blocks, h1('a') + br(1) + 'b'); check([headingMark, plain('a'), paragraphBreak(3), plain('b')], blocks, h1('a') + br(2) + 'b'); check([headingMark, plain('a'), paragraphBreak(3, '\n \n\t\n'), plain('b')], blocks, h1('a') + br(2) + 'b'); }); test('empty lines before the first block', () => { check([paragraphBreak(2), headingMark, plain('a')], [heading(2, 3)], br(2) + h1('a')); check([paragraphBreak(2, '\n \n'), listMark('- '), plain('a')], [list(entry(2, 3))], br(2) + li('a')); }); test('empty lines after the last block', () => { const fts = [headingMark, plain('a')]; const blocks = [heading(1, 2)]; check(fts, blocks, h1('a'), '# a'); check(fts, blocks, h1('a'), '# a\n'); check([...fts, lineBreak], blocks, h1('a') + br(1), '# a\n\n'); check([...fts, paragraphBreak(2)], blocks, h1('a') + br(2), '# a\n\n\n'); check([...fts, paragraphBreak(1, '\n ')], blocks, h1('a'), '# a\n '); check([...fts, paragraphBreak(1, '\n ')], blocks, h1('a') + br(1), '# a\n \n'); check([...fts, paragraphBreak(2, '\n\n ')], blocks, h1('a') + br(1), '# a\n\n '); check([...fts, paragraphBreak(2, '\n\n ')], blocks, h1('a') + br(2), '# a\n\n \n'); }); test('a message ending in a paragraph ends in inline text', () => { const twoParagraphs = [paragraph(0, 1), paragraph(2, 3)]; const oneParagraph = [paragraph(0, 1)]; check([plain('a'), paragraphBreak(2), plain('b')], twoParagraphs, div('a') + br(1) + 'b', 'a\n\nb'); check([plain('a'), paragraphBreak(3), plain('b')], twoParagraphs, div('a') + br(2) + 'b', 'a\n\n\nb'); check([paragraphBreak(2), plain('a')], [paragraph(1, 2)], br(2) + 'a', '\n\na'); check([plain('a'), paragraphBreak(1, '\n ')], oneParagraph, 'a', 'a\n '); }); test('a message ending in a paragraph with empty lines at the end', () => { const oneParagraph = [paragraph(0, 1)]; check([plain('a'), lineBreak], oneParagraph, div('a') + br(1), 'a\n\n'); check([plain('a'), paragraphBreak(2)], oneParagraph, div('a') + br(2), 'a\n\n\n'); check([plain('a'), paragraphBreak(1, '\n ')], oneParagraph, div('a') + br(1), 'a\n \n'); }); test('empty lines between list entries', () => { check( [listMark('- '), plain('a'), paragraphBreak(2), listMark('- '), plain('b'), paragraphBreak(3), listMark('- '), plain('c')], [list(entry(1, 2), entry(4, 5), entry(7, 8))], li('a') + br(1) + li('b') + br(2) + li('c') ); check( [listMark('- '), plain('a'), lineBreak, listMark(' - '), plain('b'), paragraphBreak(2), listMark('- '), plain('c')], [list(entry(1, 2, [entry(4, 5)]), entry(7, 8))], li('a') + li('b', 1) + br(1) + li('c') ); check( [listMark('- '), plain('a'), paragraphBreak(2), listMark(' - '), plain('b')], [list(entry(1, 2, [entry(4, 5)]))], li('a') + br(1) + li('b', 1) ); }); test('empty lines next to lists', () => { check( [plain('a'), paragraphBreak(3), listMark('- '), plain('b')], [paragraph(0, 1), list(entry(3, 4))], div('a') + br(2) + li('b') ); check( [listMark('- '), plain('a'), paragraphBreak(2), plain('b')], [list(entry(1, 2)), paragraph(3, 4)], li('a') + br(1) + 'b' ); }); test('empty lines next to code blocks, tables and rules', () => { const html = renderMarkdown( [fence, hardBreak, plain('x'), hardBreak, fence, paragraphBreak(2), markup('ruleMark', '---')], [{ type: 'codeBlock', content: range(2, 3) }, { type: 'rule', mark: range(6, 7) }], '' ); assert.ok(html.startsWith('
x
')); assert.ok(html.endsWith('


')); check( [plain('a'), paragraphBreak(2), markup('rowMark', '| '), plain('x'), markup('cellMark', ' |'), hardBreak, markup('delimiterRow', '|---|', { columns: ['none'] }), paragraphBreak(3), plain('b')], [paragraph(0, 1), { type: 'table', columns: ['none'], header: [range(3, 4)], rows: [] }, paragraph(8, 9)], div('a') + br(1) + '
x
' + br(2) + 'b' ); }); test('empty lines in a quote are lines of its text', () => { check( [quoteMark('> '), plain('a'), hardBreak, quoteMark('>'), hardBreak, quoteMark('> '), plain('b')], [{ type: 'quote', content: range(1, 7) }], '
a

b
' ); }); test('at most 100 empty lines in a row', () => { check([plain('a'), paragraphBreak(5000), plain('b')], [paragraph(0, 1), paragraph(2, 3)], div('a') + br(100) + 'b'); check([paragraphBreak(5000), plain('a')], [paragraph(1, 2)], br(100) + 'a'); check([plain('a'), paragraphBreak(5000)], [paragraph(0, 1)], div('a') + br(100)); check( [listMark('- '), plain('a'), paragraphBreak(5000), listMark('- '), plain('b')], [list(entry(1, 2), entry(4, 5))], li('a') + br(100) + li('b') ); }); test('plain text and inline formatting without blocks', () => { assert.strictEqual(escapeHtml('a\n\n\nb'), 'a


b'); check([{ format: { type: 'bold', singleStar: true }, text: 'a' }, plain('\nb')], undefined, 'a
b'); }); test('invalid blocks are drawn as text', () => { check([plain('a'), paragraphBreak(2), plain('b')], [{ type: 'unknown' }], 'a

b'); }); test('text outside messages keeps one gap between blocks', () => { const page = (fts, blocks, html) => assert.strictEqual(renderMarkdown(fts, blocks), html); const gap = '
'; const blocks = [heading(1, 2), paragraph(3, 4)]; page([headingMark, plain('a'), lineBreak, plain('b')], blocks, h1('a') + div('b')); page([headingMark, plain('a'), paragraphBreak(3), plain('b')], blocks, h1('a') + gap + div('b')); page([plain('a'), paragraphBreak(2), listMark('- '), plain('b')], [paragraph(0, 1), list(entry(3, 4))], div('a') + gap + li('b')); page([listMark('- '), plain('a'), paragraphBreak(2), listMark('- '), plain('b')], [list(entry(1, 2), entry(4, 5))], li('a') + li('b')); page([paragraphBreak(2), plain('a'), paragraphBreak(2)], [paragraph(1, 2)], div('a')); });