OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown / test / render.test.js

250 lines · javascript ref:
// render(markdown) -> HTML — the NATIVE projection.
//
// strip() exists for bridges: it throws formatting away to fit networks that have none. render() is
// the opposite and is what our own surfaces show, so it must keep everything the dialect allows.
//
// ⛔ It is also the one function in this package where a defect is an XSS rather than a rendering
// bug. Every text node and every attribute is escaped, and only http/https may carry an href — the
// same fail-closed posture reach.js has for privacy, applied to markup.

import test from 'brittle'
import { render, embeds } from '../lib/render.js'

test('basic formatting survives, unlike in strip()', (t) => {
  t.is(render('hello **world**'), '<p>hello <strong>world</strong></p>')
  t.is(render('*em* and ~~gone~~'), '<p><em>em</em> and <del>gone</del></p>')
  t.is(render('use `npm test`'), '<p>use <code>npm test</code></p>')
})

test('⛔ text is escaped', (t) => {
  t.is(render('a < b & c > d'), '<p>a &lt; b &amp; c &gt; d</p>')
  t.absent(render('<script>alert(1)</script>').includes('<script>'), 'raw HTML is not executed')
})

test('⛔ only http(s) carries an href', (t) => {
  t.is(render('[x](https://a.com)'), '<p><a href="https://a.com" rel="noopener noreferrer nofollow">x</a></p>')
  t.is(render('[x](javascript:alert(1))'), '<p>x</p>', 'a javascript: target renders as its label, no link')
  t.is(render('[x](data:text/html,hi)'), '<p>x</p>')
})

test('⛔ an attribute cannot break out', (t) => {
  const html = render('[x](https://a.com/"onmouseover="alert(1))')
  // The substring `onmouseover=` DOES appear — inside the escaped href value, inert. Asserting its
  // absence was wrong and would have failed correct code. The property that matters is that the
  // quotes are escaped, so the value cannot close and start a new attribute.
  t.ok(html.includes('&quot;onmouseover=&quot;'), 'the quotes are escaped, so nothing breaks out')
  t.is(html.match(/href="/g).length, 1, 'exactly one href — no injected attribute')
  t.absent(/\son\w+="/.test(html), 'no event-handler attribute exists in the output')
})

test('a mention is a link to the user, not an http link', (t) => {
  const html = render('hi [@alice](favela://abc123)')
  t.ok(html.includes('class="mention"'), 'marked as a mention')
  t.ok(html.includes('data-target="favela://abc123"'), 'carries the target for the surface to resolve')
  t.absent(html.includes('href="favela://'), 'never an href — the browser cannot follow it')
})

test('wiki-links and hashtags render as our own things', (t) => {
  t.ok(render('see [[The Page]]').includes('class="wikilink"'))
  t.ok(render('see [[The Page|that]]').includes('>that<'), 'the label wins')
  const tag = render('about #p2p today')
  t.ok(tag.includes('class="hashtag"') && tag.includes('#p2p'), 'the hash is kept, as in strip §3.7')
})

test('★ :::spoiler is CONCEALED here — the opposite of the bridge projection', (t) => {
  const html = render(':::spoiler\nthe ending\n:::')
  t.ok(html.includes('<details'), 'a real spoiler')
  t.ok(html.includes('the ending'))
  // markdown-strip.md §4.8 reveals it for bsky because atproto has no such thing. That asymmetry is
  // the whole reason library.md §5 exists, and this is the side that gets it right.
})

test('blocks: headings, lists, quotes, code, rules', (t) => {
  t.is(render('# Title'), '<h1>Title</h1>')
  t.is(render('- a\n- b'), '<ul><li>a</li><li>b</li></ul>')
  t.is(render('> quoted'), '<blockquote><p>quoted</p></blockquote>')
  t.ok(render('```js\nconst x = 1\n```').includes('<pre><code class="language-js">'))
  t.is(render('---'), '<hr>')
})

test('a code block escapes its contents', (t) => {
  t.ok(render('```\n<b>not bold</b>\n```').includes('&lt;b&gt;not bold&lt;/b&gt;'))
})

test('tables render as tables', (t) => {
  const html = render('| a | b |\n|---|---|\n| 1 | 2 |')
  t.ok(html.includes('<table>') && html.includes('<th>a</th>') && html.includes('<td>1</td>'))
})

test('images render, with escaped alt and an http-only src', (t) => {
  t.ok(render('![a "b"](https://x/i.png)').includes('<img src="https://x/i.png" alt="a &quot;b&quot;">'))
  // ⚠ '' rather than '<p></p>'. The intent — "drops the image entirely" — is unchanged and now
  // fully met: an empty paragraph is not nothing, it is a blank line in every consumer that renders
  // it, which a live feed showed on every picture post.
  t.is(render('![x](javascript:alert(1))'), '', 'a non-http src drops the image entirely')
})

test('render is TOTAL — it never throws', (t) => {
  for (const bad of ['', '   ', '[', '[](', '![', '```', ':::', '|', '~~~', null, undefined, 42]) {
    t.execution(() => render(bad))
  }
})

test('unknown syntax degrades to text, per the closed dialect', (t) => {
  t.is(render('~single~'), '<p>~single~</p>', 'single tilde is literal (§2.1)')
})

// ── embedded files (record-vocabulary.md §3.5) ────────────────────────────────────────────────
//
// `favela://blob/<oid>` names a file the overlay holds. Bytes move P2P; this library only asks the
// SURFACE where it can be read from, and has no opinion about the answer.

const OID = 'a3f9'.repeat(8)
const BLOB = 'favela://blob/' + OID
const resolver = (mimeType, src = '/blob/' + OID) => ({ resolveBlob: () => ({ src, mimeType }) })

test('★ an image blob renders an <img> from the surface\'s own src', (t) => {
  const html = render('![a cat](' + BLOB + ')', resolver('image/png'))
  t.ok(/<img class="embed" loading="lazy" src="\/blob\/a3f9[a-f0-9]*" alt="a cat">/.test(html),
    'src and alt both survive')
  // ⛔ Wrapped in a link to the file, so a constrained preview does not throw the picture away.
  t.ok(/<a class="lightbox" href="\/blob\//.test(html), 'and it opens full size, with no script')
})

test('★ a video blob renders <video controls>, not a broken image', (t) => {
  const html = render('![clip](' + BLOB + ')', resolver('video/mp4'))
  t.ok(/<video class="embed" controls preload="metadata" src="\/blob\//.test(html))
  t.absent(html.includes('<img'), 'not an img — the bytes decide the element, not the syntax')
})

test('a PDF link is shown inline; any other file is a link', (t) => {
  const pdf = render('[report](' + BLOB + ')', resolver('application/pdf'))
  t.ok(/<embed class="doc" type="application\/pdf"/.test(pdf), 'the browser already knows how')

  const csv = render('[rows](' + BLOB + ')', resolver('text/csv'))
  t.ok(/<a class="file" href="\/blob\//.test(csv))
  t.ok(csv.includes('rows'), 'the label is kept')
})

test('⛔ a blob is matched BEFORE a mention, or the file silently vanishes', (t) => {
  // `isMention` is bare `^favela://`, so it matches a blob URI too. Testing mentions first turns
  // every embed into a mention span — no error, no image, nothing to debug.
  const html = render('[pic](' + BLOB + ')', resolver('image/png'))
  t.absent(html.includes('class="mention"'), 'not a mention')
  t.ok(html.includes('class="file"'), 'a file')
  // And a real mention still is one.
  const m = render('[@alice](favela://' + 'ab'.repeat(32) + ')', resolver('image/png'))
  t.ok(m.includes('class="mention"'), 'a mention is still a mention')
})

test('⛔ with no resolver, an embed renders as NOTHING', (t) => {
  // Correct for a caller that cannot serve files — it must not emit a src it cannot honour.
  t.absent(render('![x](' + BLOB + ')').includes('<img'), 'no resolver')
  t.absent(render('![x](' + BLOB + ')', { resolveBlob: () => null }).includes('<img'), 'unknown oid')
  t.is(render('![x](' + BLOB + ')', resolver('application/pdf')).replace(/<\/?p>/g, ''), '',
    'and a non-image type is not forced into an <img>')
})

test('⛔ the resolver\'s output is ESCAPED', (t) => {
  const html = render('![x](' + BLOB + ')', resolver('image/png', '"><script>x</script>'))
  t.absent(html.includes('<script>'), 'escaped')
  t.is((html.match(/<img /g) || []).length, 1, 'exactly one img — nothing injected another')
})

test('⛔ the author-URL allowlist is UNCHANGED by any of this', (t) => {
  // `isHttp` guards what an AUTHOR writes, which is the untrusted input. Adding blobs must not
  // widen it — a resolver is the surface's own code and is a different trust question entirely.
  const o = resolver('image/png')
  t.absent(render('![x](javascript:alert(1))', o).includes('<img'), 'javascript: still dropped')
  t.absent(render('![x](data:image/png;base64,AAAA)', o).includes('<img'), 'data: still dropped')
  t.absent(render('[x](javascript:alert(1))', o).includes('href'), 'and links too')
  t.ok(render('![x](https://example.com/a.png)', o).includes('<img'), 'http still works')
})

test('★ embeds() reports every blob a record references, in source order', (t) => {
  const A = 'a1'.repeat(16); const B = 'b2'.repeat(16)
  const md = '![a cat](favela://blob/' + A + ')\n\nand [the report](favela://blob/' + B + ')'
  const got = embeds(md)
  t.is(got.length, 2)
  t.alike(got[0], { oid: A, alt: 'a cat', kind: 'image' })
  t.alike(got[1], { oid: B, alt: 'the report', kind: 'link' }, 'a link label is its own children')
})

test('⛔ embeds() uses the PARSE, so a blob URI inside a code fence is not a reference', (t) => {
  // A regex would report this and the surface would fetch a file the renderer never shows — the
  // two disagreeing about what a record references is exactly the bug worth preventing.
  const md = '```\n![x](favela://blob/' + 'c3'.repeat(16) + ')\n```'
  t.alike(embeds(md), [], 'code is not a reference')
  t.alike(embeds('no embeds here'), [])
  t.alike(embeds(null), [], 'total — never throws')
})

test('★ consecutive image embeds fold into ONE lighttable', (t) => {
  const A = 'a1'.repeat(16); const B = 'b2'.repeat(16); const C = 'c3'.repeat(16)
  const files = { [A]: 'image/png', [B]: 'image/jpeg', [C]: 'image/webp' }
  const o = { resolveBlob: (oid) => (files[oid] ? { src: '/blob/' + oid, mimeType: files[oid] } : null) }

  // What the composer produces: one embed per attachment, blank line between.
  const md = '![one](favela://blob/' + A + ')\n\n![two](favela://blob/' + B + ')\n\n![three](favela://blob/' + C + ')'
  const html = render(md, o)
  t.is((html.match(/<div class="lighttable">/g) || []).length, 1, 'one grid, not three')
  t.is((html.match(/<img /g) || []).length, 3, 'holding all three')
  // ⛔ Order is preserved, which is the whole reason this is a grid and not CSS columns.
  t.ok(html.indexOf('/blob/' + A) < html.indexOf('/blob/' + B), 'in the order they were written')
  t.ok(html.indexOf('/blob/' + B) < html.indexOf('/blob/' + C))
})

test('⛔ a single image is NOT a grid, and prose with a picture in it is left alone', (t) => {
  const A = 'a1'.repeat(16)
  const o = { resolveBlob: () => ({ src: '/blob/' + A, mimeType: 'image/png' }) }

  t.absent(render('![one](favela://blob/' + A + ')', o).includes('lighttable'), 'one picture is not a grid')

  // ⛔ Grouping a paragraph that also has words would silently restructure someone's writing — a
  // much worse failure than not grouping.
  const prose = render('look at ![this](favela://blob/' + A + ') closely\n\n![x](favela://blob/' + A + ')', o)
  t.absent(prose.includes('lighttable'), 'text alongside an image breaks the run')
  t.ok(prose.includes('look at'), 'and the words survive')
})

test('a video or an unresolved embed breaks the run rather than leaving a hole', (t) => {
  const A = 'a1'.repeat(16); const V = 'd4'.repeat(16); const U = 'e5'.repeat(16)
  const files = { [A]: 'image/png', [V]: 'video/mp4' }
  const o = { resolveBlob: (oid) => (files[oid] ? { src: '/blob/' + oid, mimeType: files[oid] } : null) }

  const withVideo = render('![a](favela://blob/' + A + ')\n\n![v](favela://blob/' + V + ')', o)
  t.absent(withVideo.includes('lighttable'), 'a video has controls and its own aspect')
  t.ok(withVideo.includes('<video'), 'and still renders')

  const withUnknown = render('![a](favela://blob/' + A + ')\n\n![u](favela://blob/' + U + ')', o)
  t.absent(withUnknown.includes('lighttable'), 'an embed that renders as nothing would leave a hole')
})

// ⛔ A PARAGRAPH THAT RENDERS TO NOTHING IS NOTHING.
//
// `image()` drops a `favela://blob/…` embed entirely when no resolver is supplied — correctly: an
// <img> with no usable src is noise. But the paragraph around it still rendered, so a post whose
// only content was a picture produced `<p>text</p><p></p>`.
//
// MEASURED on ashatron 2026-09-07: a bridge lifting embeds into AS2 attachments got exactly that,
// and a blank line would have shown in every receiving client. It is not specific to that consumer
// — the surface hits the same shape whenever a blob fails to resolve.
test('an empty paragraph is omitted, not emitted', (t) => {
  const oid = 'a1'.repeat(16)
  const html = render('look\n\n![alt](favela://blob/' + oid + ')')
  t.absent(html.includes('<p></p>'), 'no empty paragraph: ' + html)
  t.ok(html.includes('look'), 'and the real text survives')
})

test('⚠ a paragraph with only whitespace is also nothing', (t) => {
  t.absent(render('a\n\n \n\nb').includes('<p></p>'))
})

test('⛔ a resolved image still renders — this must not drop real content', (t) => {
  const oid = 'b2'.repeat(16)
  const html = render('![cat](favela://blob/' + oid + ')', {
    resolveBlob: () => ({ src: '/blob/' + oid, mimeType: 'image/webp' })
  })
  t.ok(html.includes('<img'), 'the picture is there: ' + html.slice(0, 80))
})