// 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 < b & c > 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('"onmouseover="'), '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('<b>not bold</b>'))
})
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('').includes('<img src="https://x/i.png" alt="a "b"">'))
// ⚠ '' 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(')'), '', '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('', 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('', 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('').includes('<img'), 'no resolver')
t.absent(render('', { resolveBlob: () => null }).includes('<img'), 'unknown oid')
t.is(render('', 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('', 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(')', o).includes('<img'), 'javascript: still dropped')
t.absent(render('', o).includes('<img'), 'data: still dropped')
t.absent(render('[x](javascript:alert(1))', o).includes('href'), 'and links too')
t.ok(render('', 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 = '\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 + ')\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 = '\n\n\n\n'
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('', 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  closely\n\n', 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('\n\n', 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('\n\n', 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')
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('', {
resolveBlob: () => ({ src: '/blob/' + oid, mimeType: 'image/webp' })
})
t.ok(html.includes('<img'), 'the picture is there: ' + html.slice(0, 80))
})