// Row 148 (#1642, ruling sage-0507): the Markdown preview makes elements and text nodes only. // The module is loaded into a bare context with a tiny document, so what it builds is a tree // the tests can read: no markup is ever parsed on the way, which is the property under test. import test from 'node:test'; import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { createContext, runInContext, runInNewContext } from 'node:vm'; import { fileURLToPath } from 'node:url'; const FILE = fileURLToPath(new URL('../src/public/markdown-preview.js', import.meta.url)); const source = readFileSync(FILE, 'utf8'); class Node { constructor(tag) { this.tag = tag; this.children = []; this.attrs = {}; this.className = ''; this.text = null; } set textContent(v) { this.children = [new Text(String(v))]; } get textContent() { return this.children.map(c => c.textContent).join(''); } setAttribute(k, v) { this.attrs[k] = String(v); } appendChild(c) { this.children.push(c); return c; } get lastChild() { return this.children.at(-1); } } class Text { constructor(t) { this.tag = '#text'; this.data = t; } get textContent() { return this.data; } } const doc = { createElement: tag => new Node(tag), createTextNode: t => new Text(String(t)), createDocumentFragment: () => new Node('#fragment'), }; const { MosaicMarkdown } = (() => { const window = {}; runInNewContext(source, { window }); return window; })(); // `meter` receives {work, budget}: the render's look-ahead account, which the tests assert on instead of the clock. const tree = (md, meter) => MosaicMarkdown.render(md, doc, meter); // A compact view: children, text as itself. const show = n => n.tag === '#text' ? n.data : `<${n.tag}${Object.entries(n.attrs).map(([k, v]) => ` ${k}=${v}`).join('')}>${n.children.map(show).join('')}`; const html = md => tree(md).children.map(show).join(''); const find = (n, tag, out = []) => { if (n.tag === tag) out.push(n); for (const c of n.children ?? []) find(c, tag, out); return out; }; test('the preview source names none of the four string-to-markup sinks', () => { for (const sink of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write']) assert.equal(source.includes(sink), false, sink); assert.equal(/\beval\b|new Function|DOMParser|createContextualFragment|srcdoc/.test(source), false, 'no other way to parse markup or run text'); assert.ok(source.includes('createElement') && source.includes('textContent')); }); test('headings, paragraphs, emphasis and inline code render', () => { assert.equal(html('# One\n\n###### Six'), '

One

Six
'); assert.equal(html('Setext\n===\n\nTwo\n---'), '

Setext

Two

'); assert.equal(html('a **b** and *c* and _d_ and `e`\nnext'), '

a b and c and d and e\nnext

'); assert.equal(html('snake_case_word and 2*3*4'), '

snake_case_word and 234

'); assert.equal(html('``a ` b``'), '

a ` b

'); }); test('lists, block quotes and code blocks render', () => { assert.equal(html('- a\n- b **x**\n\n1. one\n2. two'), '
  1. one
  2. two
'); assert.equal(html('3. three\n4. four'), '
  1. three
  2. four
'); assert.equal(html('- a\n - nested\n- b'), ''); assert.equal(html('> quoted\n> # head\n\nafter'), '

quoted

head

after

'); assert.equal(html('```js\nconst a = "";\n```\n'), '
const a = "";
'); assert.equal(html('~~~\nopen'), '
open
'); }); test('a link is made for http, https and relative targets, with rel and the target shown', () => { const links = find(tree('[a](https://example.com/x?y=1) [b](http://example.com) [c](docs/a.md) [d](#top) [e](../up.md) [f](/root)'), 'a'); assert.deepEqual(links.map(a => a.attrs.href), ['https://example.com/x?y=1', 'http://example.com', 'docs/a.md', '#top', '../up.md', '/root']); for (const a of links) { assert.equal(a.attrs.rel, 'noopener noreferrer'); assert.equal(a.attrs.target, '_blank'); } assert.equal(html('[t](https://example.com)'), '

t (https://example.com)

'); assert.equal(find(tree('[t](https://example.com)'), 'span')[0].className, 'md-target'); assert.equal(find(tree('[a *b* `c`](x.md)'), 'a')[0].children.map(show).join(''), 'a b c'); }); test('a link with any other scheme, or an evasion of the scheme, stays source text', () => { for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'data:text/html,', 'vbscript:x', 'file:///etc/passwd', 'blob:x', 'ftp://x/y', 'mailto:a@b.c', 'java\tscript:alert(1)', 'java\nscript:alert(1)', ' javascript:alert(1)', 'javascript:alert(1)'.replace(':', ':'), '\u0001javascript:x', '//evil.example/x', '\\\\evil\\x', '/\\evil.example', 'http:///x', 'https:', 'a' + String.fromCharCode(0x202e) + 'b', 'a' + String.fromCharCode(0x200b) + 'b']) { const md = `[click](<${target}>)`, t = tree(md); assert.deepEqual(find(t, 'a'), [], JSON.stringify(target)); assert.equal(t.textContent, md, `${JSON.stringify(target)} stays as written`); } // The same without the angle brackets, for the targets that need none. for (const target of ['javascript:alert(1)', 'data:text/html;base64,AAAA', 'JAVASCRIPT:x']) { const t = tree(`[click](${target})`); assert.deepEqual(find(t, 'a'), []); assert.equal(t.textContent, `[click](${target})`); } assert.equal(MosaicMarkdown.linkTarget('javascript:x'), null); assert.equal(MosaicMarkdown.linkTarget('https://a.example/b'), 'https://a.example/b'); assert.equal(MosaicMarkdown.linkTarget('a.md'), 'a.md'); }); test('an image shows alt text and URL, is never an img element and never loads', () => { const t = tree('![alt text](https://tracker.example/p.png) and ![x](javascript:alert(1))'); assert.deepEqual(find(t, 'img'), []); assert.equal(t.textContent, '[image: alt text] https://tracker.example/p.png and [image: x] javascript:alert(1)'); assert.deepEqual(find(t, 'a'), [], 'an image destination is never a link either'); }); test('raw HTML, entities and unsupported blocks show as source text, never as elements', () => { const samples = ['', '', 'x', '', 'bold text', '', '
', '| a | b |\n|---|---|\n| 1 | 2 |', '<script>', '***', '']; const allowed = new Set(['#fragment', 'p', 'ul', 'ol', 'li', 'em', 'strong', 'code', 'pre', 'a', 'span', 'blockquote', '#text', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6']); for (const md of samples) { const t = tree(md), walk = n => { assert.ok(allowed.has(n.tag), `${md} made <${n.tag}>`); n.children?.forEach(walk); }; walk(t); if (md !== '***') assert.equal(t.textContent.replace(/\s+/g, ' ').trim(), md.replace(/\s+/g, ' ').trim(), `${md} shows as written`); } assert.deepEqual(find(tree(''), 'img'), []); }); test('markup inside code and inside link text stays text', () => { assert.equal(html('` "> [x](https://ok.example) *em* `code` ![i](https://ok.example/p.png)'; // The same tail, alone, is made into a link, emphasis and code: only the budget keeps it as text. const alone = tree(tail); assert.equal(find(alone, 'a').length, 1); assert.equal(find(alone, 'em').length, 1); assert.equal(find(alone, 'code').length, 1); const meter = {}, t = tree(head + tail, meter); assert.ok(meter.work >= meter.budget, 'the head spent the budget before the tail'); const allowed = new Set(['#fragment', 'p', '#text']); const walk = n => { assert.ok(allowed.has(n.tag), `made <${n.tag}>`); n.children?.forEach(walk); }; walk(t); for (const tag of ['a', 'em', 'strong', 'code', 'img', 'script', 'span']) assert.deepEqual(find(t, tag), [], tag); assert.equal(t.textContent, head + tail, 'every character is still shown, as written'); }); // The budget must not touch a normal document. This one is built, not stored: headings with closing // marks and trailing blanks, a setext heading, emphasis, links of each kind, images, nested lists, a // block quote, a fenced block, raw HTML, stray openers and a few long paragraphs. Its rendering is // pinned by the SHA-256 of the compact view, taken from the round 1 preview (no budget), so any // difference in what a normal file shows fails here. To take the pin again, render sampleDocument() // with a preview that has no budget and hash show() of the fragment's children. function sampleDocument() { const T = '`', out = []; const words = n => Array.from({ length: n }, (_, i) => `word${(i * 7) % 31}`).join(' '); for (let n = 0, size = 0; size < 200 * 1024; n++) { const block = [ `## Section ${n} ## `, '', `A paragraph with **strong ${n}**, *emphasis*, _under_, ${T}code ${n}${T} and a [link](https://example.com/p/${n}?q=1 "title") plus [local](docs/page-${n}.md) and ![pic](https://example.com/i/${n}.png).`, `Second line with snake_case_name, 2*3*4, a stray [bracket and a lone ${T} tick, html and & text. `, '', `- item ${n} with **bold**`, ' - nested [x]() item', `- last ${T}code${T}`, '', '3. third', '4. fourth *four*', '', `> quoted ${n} *text*`, '> more', '', `${T}${T}${T}js`, `const a = ""; // ${n}`, `${T}${T}${T}`, '', `Setext ${n}`, '===', '', '[bad](javascript:alert(1)) and [ok](#top) and [empty]()', '', '---', '', ...(n % 5 === 0 ? [`[A long paragraph ${n}: ${words(500)} *closed* and _also_ and ${T}code${T}, then an open * star and an open [ bracket.`, ''] : []), ]; out.push(...block); size += block.reduce((sum, line) => sum + line.length + 1, 0); } return out.join('\n'); } test('a normal 200 KiB document renders exactly as it did without a budget, and spends little of it', () => { const md = sampleDocument(), meter = {}; assert.ok(md.length >= 200 * 1024 && md.length < 260 * 1024, `${md.length} characters`); const t = tree(md, meter); const sections = (md.match(/^## Section /gm) ?? []).length; assert.ok(sections > 50, `${sections} sections`); assert.ok(find(t, 'a').length >= 2 * sections && find(t, 'em').length >= 3 * sections && find(t, 'code').length >= 2 * sections && find(t, 'pre').length === sections, 'the sample exercises each element'); assert.equal(meter.budget, budgetOf(md.length)); assert.ok(meter.work * 2 < meter.budget, `a normal document used ${meter.work} of ${meter.budget}`); const digest = createHash('sha256').update(t.children.map(show).join('')).digest('hex'); assert.equal(digest, '5df9165a60cdd36d511fee74b2a25854e58ef4eade30f1acfd55f3c518cee249', 'the rendering matches the round 1 preview'); }); // Round 3 (B1, sage-0604): `.` stops at U+2028 and U+2029, and the split on "\n" leaves both inside a // line. Without the `s` flag on FENCE, ATX and ITEM, `$` then failed behind a long run and the engine // retried the run from every offset: 2.2 s for a 64 KiB line, none of it visible to the meter. The clock // cannot be used for that, so these are semantic: without the flag the line is not a heading, an item // or a fence at all, it falls through to a paragraph, and these assertions fail on that. const SEPARATORS = [['U+2028', String.fromCharCode(0x2028)], ['U+2029', String.fromCharCode(0x2029)]]; test('a line separator inside a heading, a list item or a fence line does not end the marker line', () => { for (const [name, sep] of SEPARATORS) { assert.equal(html(`# a${sep}b`), `

a${sep}b

`, `heading with ${name}`); assert.equal(html(`- a${sep}b`), `
  • a${sep}b
`, `bullet with ${name}`); assert.equal(html(`1. a${sep}b`), `
  1. a${sep}b
`, `numbered item with ${name}`); for (const fence of ['```', '~~~']) assert.equal(html(`${fence}a${sep}b\ncode\n${fence}`), '
code
', `${fence} info string with ${name}`); // The shapes that were slow: the marker, a long run, then the separator. Short here; the run's length // does not matter to the result, only that the separator follows it. assert.equal(find(tree(`# ${' '.repeat(50)}${sep}b`), 'h1').length, 1, `heading, blank run, ${name}`); assert.equal(find(tree(`- ${' '.repeat(50)}${sep}b`), 'li').length, 1, `item, blank run, ${name}`); const run = '`'.repeat(50), pre = find(tree(`${run}${sep}x\ncode\n${run}`), 'pre'); assert.equal(pre.length, 1, `fence, backtick run, ${name}`); assert.equal(pre[0].textContent, 'code', `fence body, ${name}`); } }); // A thematic break is told from a list item by a walk, not the regex it replaced. That regex, // /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/, threw RangeError ("Maximum call stack size exceeded") on one // line of about 2 MiB of marks (4 MiB when spaced). The preview has no rule element: a break line is // source text in a paragraph, and its only effect is that it does not open a list. const OLD_RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/; const listIn = md => { const t = tree(md); return find(t, 'ul').length + find(t, 'ol').length > 0; }; test('a thematic-break line is a paragraph, every other marker line opens a list', () => { for (const line of ['* * *', '- - -', '***', '---', '___', '_ _ _', ' * * *', ' - - -', '* * *', '* * * * *', '- - - ', '*\t*\t*', '-\t-\t-', '- - - -']) assert.equal(listIn(line), false, JSON.stringify(line)); for (const line of ['- -', '* *', '- - x', '* a * *', '- * -', '* - *', '- _ -', '- -', '*\t*', '- - -x']) assert.equal(listIn(line), true, JSON.stringify(line)); assert.equal(html('* * *'), '

* * *

'); assert.equal(html('- a\n- - -\n- b'), '
  • a\n- - -
  • b
', 'a break line inside a list item is text, the next marker line is the next item'); }); test('every short line over space, tab, - * _ and a letter is a break exactly when the old regex said so', () => { const alphabet = [' ', '\t', '-', '*', '_', 'a']; let checked = 0, rules = 0; const walk = (prefix, left) => { if (prefix) { const seen = prefix.replace(/^[ \t]+/, w => w.replace(/\t/g, ' ')); // Only a line that would otherwise open a list can show the difference. const wouldBeItem = /^ {0,3}[-*+]([ \t]|$)/.test(seen); if (wouldBeItem) { checked++; if (OLD_RULE.test(seen)) rules++; assert.equal(listIn(prefix), !OLD_RULE.test(seen), JSON.stringify(prefix)); } } if (left) for (const c of alphabet) walk(prefix + c, left - 1); }; walk('', 6); assert.ok(checked > 5000 && rules > 100, `${checked} lines checked, ${rules} of them breaks`); }); test('one line of marks does not throw, at 4 MiB, whatever the mark or the spacing', () => { const MIB4 = 4 * 1024 * 1024; for (const unit of ['-', '- ', '*', '* ', '_', '_ ', '-\t', ' -', ' * ']) for (const lead of ['', ' ']) { const line = lead + unit.repeat(Math.ceil(MIB4 / unit.length)); let tree_; assert.doesNotThrow(() => { tree_ = tree(line); }, JSON.stringify(unit)); assert.equal(find(tree_, 'ul').length, 0, `${JSON.stringify(unit)} is a break, not a list`); assert.equal(find(tree_, 'p').length, 1, JSON.stringify(unit)); } // Not a break, same size: a list item whose text is the long run, and the same with one stray letter. assert.doesNotThrow(() => tree('- ' + 'a '.repeat(MIB4 / 2))); assert.doesNotThrow(() => tree('-'.repeat(MIB4) + 'a')); }); test('CRLF and tabs are read like LF and spaces; empty input renders nothing', () => { assert.equal(html('# a\r\n\r\ntext\r\nmore'), '

a

text\nmore

'); assert.equal(tree('').children.length, 0); assert.equal(tree('\n\n \n').children.length, 0); });