// render(markdown) -> HTML — the NATIVE projection.
//
// `strip()` is for bridges: it throws formatting away to fit networks that have none. This is the
// opposite, and it is what our own surfaces show. Both parse through `dialect.js` so they can never
// disagree about what the markup means.
//
// ⛔ This is the one function in the package where a defect is an XSS rather than a rendering bug,
// so it is fail-closed in the same way `reach.js` is for privacy:
// - every text node and every attribute value is escaped;
// - only http/https may carry an `href` or `src` — anything else renders as its label, or is
// dropped, and NEVER becomes a link;
// - raw HTML in the source is emitted as escaped text, never as markup.
//
// ★ Note the spoiler asymmetry, which is the thesis in one node type: `:::spoiler` is REVEALED by
// strip (atproto has no such concept) and CONCEALED here. Same record, two projections, and only
// one of them is lossy — which is why `library.md` §5 asks the composer to say so.
import { fromMarkdown } from 'mdast-util-from-markdown'
import { MICROMARK, WIKILINK_RE, HASHTAG_RE, isHttp, isMention, blobOid } from './dialect.js'
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
// Text runs get the two LEXICAL rules the dialect owns (§3.6, §3.7) — wiki-links and hashtags are
// not structural, so micromark never sees them and they are applied here, after escaping, so a page
// name or a tag can never inject markup.
function inlineText (raw) {
let out = esc(raw)
out = out.replace(WIKILINK_RE, (_m, page, label) =>
'<span class="wikilink" data-page="' + esc(page) + '">' + esc(label !== undefined ? label : page) + '</span>')
out = out.replace(HASHTAG_RE, (_m, lead, whole, tag) =>
lead + '<span class="hashtag" data-tag="' + esc(tag) + '">' + esc(whole) + '</span>')
return out
}
const children = (node, o) => (node.children || []).map((c) => toHtml(c, o)).join('')
/**
* The images in a paragraph that contains NOTHING ELSE, or null.
*
* ⛔ "Nothing else" is strict: any text with content, any link, any video, and this is prose with a
* picture in it rather than a row of pictures. Grouping those would silently restructure someone's
* writing, which is a much worse failure than not grouping.
*
* Whitespace between images does not count — a composer appending embeds produces exactly that.
*/
function imageRun (node, o) {
if (!node || node.type !== 'paragraph') return null
const out = []
for (const c of node.children || []) {
if (c.type === 'image') {
const b = blob(o, c.url || '')
// ⛔ Only resolved IMAGES group. A video has controls and its own aspect; an unresolved embed
// renders as nothing and would leave a hole in the grid.
if (!b || !b.mimeType.startsWith('image/')) return null
out.push(c)
} else if (c.type === 'text' && !String(c.value || '').trim()) {
continue
} else {
return null
}
}
return out.length ? out : null
}
/**
* Walk the top level, folding RUNS of image-only paragraphs into one lighttable.
*
* ⛔ A run, not a paragraph: the composer appends one embed per attachment separated by blank lines,
* so three attachments are three paragraphs and grouping per-paragraph would do nothing at all —
* which is exactly what it did before this.
*
* ⚠ A single image is left alone. One picture is not a grid, and wrapping it in one would change
* how every existing post renders for no gain.
*/
function topLevel (node, o) {
const kids = node.children || []
const out = []
let i = 0
while (i < kids.length) {
const run = []
let j = i
for (;;) {
const imgs = imageRun(kids[j], o)
if (!imgs) break
run.push(...imgs)
j++
}
if (run.length > 1) {
out.push('<div class="lighttable">' + run.map((n) => image(n, o)).join('') + '</div>')
i = j
} else {
out.push(toHtml(kids[i], o))
i++
}
}
return out.join('')
}
/**
* An embedded file: `favela://blob/<oid>` (record-vocabulary.md §3.5).
*
* ⛔ The library has NO OPINION about the URL scheme. `resolveBlob(oid)` belongs to the surface —
* an HTTP surface returns a loopback path, a native one returns whatever it can display — and this
* only escapes what comes back. Baking `http` in here would make a pure library assume a browser,
* and the surfaces are not all browsers. The bytes themselves never move over HTTP: they arrive
* P2P through hyper-overlay, and the surface is only saying where it can read them from.
*
* ⛔ It is NOT run through `isHttp`. That allowlist guards AUTHOR-supplied URLs, which are the
* untrusted input it exists for; a resolver is the surface's own code and no author can influence
* what it returns.
*
* Absent resolver, unknown oid, or a type this element cannot show ⇒ nothing, the same fail-closed
* posture the rest of this file has.
*/
function blob (o, url) {
const oid = blobOid(url)
if (!oid) return null
const r = o && typeof o.resolveBlob === 'function' ? o.resolveBlob(oid) : null
if (!r || typeof r.src !== 'string' || r.src === '') return null
return { src: r.src, mimeType: typeof r.mimeType === 'string' ? r.mimeType : '' }
}
function link (node, o) {
const url = node.url || ''
// ⛔ BLOB FIRST. `favela://blob/<oid>` also matches `isMention` (which is bare `^favela://`), so
// testing mentions first turns every embed into a mention span and the file silently vanishes.
const b = blob(o, url)
if (b) {
// A PDF is shown inline because a browser already knows how; anything else is a link. Either
// way the bytes came P2P and the surface's resolver said where to read them.
if (b.mimeType === 'application/pdf') {
return '<embed class="doc" type="application/pdf" src="' + esc(b.src) + '">'
}
return '<a class="file" href="' + esc(b.src) + '">' + children(node, o) + '</a>'
}
// A mention is OURS to resolve, so it never becomes an href — a browser cannot follow favela://
// and a surface should not be tempted to try. The target rides in a data attribute.
if (isMention(url)) {
return '<span class="mention" data-target="' + esc(url) + '">' + children(node) + '</span>'
}
// §3.2 — anything not http/https degrades to its label with NO link. That covers javascript:,
// data:, vbscript: and every scheme nobody has invented yet, because it is an allowlist.
if (!isHttp(url)) return children(node)
return '<a href="' + esc(url) + '" rel="noopener noreferrer nofollow">' + children(node) + '</a>'
}
function image (node, o) {
const alt = esc(node.alt || '')
const b = blob(o, node.url || '')
if (b) {
if (b.mimeType.startsWith('image/')) {
// ⛔ A PREVIEW, wrapped in a link to the file itself. An embed rendered at its natural size
// takes over a timeline — a 4000px photo is not a post, it is a wall — but a preview that
// cannot be opened has thrown the picture away. The surface constrains `.embed` and the
// anchor gives the full file back, with no script involved.
return '<a class="lightbox" href="' + esc(b.src) + '">' +
'<img class="embed" loading="lazy" src="' + esc(b.src) + '" alt="' + alt + '">' +
'</a>'
}
// ⛔ `![]()` on a video is deliberate, not a mistake: §3.5 has the embed pipeline consuming
// images in source order, and a video is the same authoring gesture with different bytes.
if (b.mimeType.startsWith('video/')) {
return '<video class="embed" controls preload="metadata" src="' + esc(b.src) + '"></video>'
}
// A file that is neither is not an image, whatever the author wrote. Dropped, not guessed.
return ''
}
if (!isHttp(node.url || '')) return '' // dropped entirely — an <img> with no usable src is noise
return '<img src="' + esc(node.url) + '" alt="' + alt + '">'
}
// :::spoiler / :::cw <text> / :::details — the pinned set (library.md §5).
function directive (node, o) {
const name = node.name || ''
const inner = children(node, o)
if (name === 'spoiler') return '<details class="spoiler"><summary>spoiler</summary>' + inner + '</details>'
if (name === 'cw') {
const label = (node.attributes && node.attributes.label) || textOf(node.children && node.children[0]) || 'content warning'
return '<details class="cw"><summary>' + esc(label) + '</summary>' + inner + '</details>'
}
if (name === 'details') return '<details>' + inner + '</details>'
// An unknown directive contributes its CONTENTS, matching strip §4.8 — the dialect is closed, so
// an unrecognised name is not markup and must not vanish.
return inner
}
const textOf = (n) => (n && n.type === 'paragraph' ? (n.children || []).map((c) => c.value || '').join('') : '')
function toHtml (node, o) {
if (!node || typeof node !== 'object') return ''
switch (node.type) {
case 'root': return topLevel(node, o)
case 'paragraph': {
// ⛔ A paragraph that renders to nothing IS nothing. `image()` drops an unresolvable embed
// deliberately — an <img> with no usable src is noise — but the paragraph around it used to
// render anyway, so a post whose only content was a picture produced `<p></p>` and every
// consumer showed a blank line. Measured against a live feed 2026-09-07.
const inner = children(node, o)
return inner.trim() ? '<p>' + inner + '</p>' : ''
}
case 'text': return inlineText(node.value)
case 'strong': return '<strong>' + children(node, o) + '</strong>'
case 'emphasis': return '<em>' + children(node, o) + '</em>'
case 'delete': return '<del>' + children(node, o) + '</del>'
case 'inlineCode': return '<code>' + esc(node.value) + '</code>'
case 'break': return '<br>'
case 'thematicBreak': return '<hr>'
case 'link': return link(node, o)
case 'image': return image(node, o)
case 'heading': {
const d = Math.min(Math.max(node.depth || 1, 1), 6)
return '<h' + d + '>' + children(node, o) + '</h' + d + '>'
}
case 'blockquote': return '<blockquote>' + children(node, o) + '</blockquote>'
case 'list': return node.ordered ? '<ol>' + children(node, o) + '</ol>' : '<ul>' + children(node, o) + '</ul>'
case 'listItem': {
const inner = (node.children || []).map((c) =>
c.type === 'paragraph' ? children(c, o) : toHtml(c, o)).join('')
return '<li>' + inner + '</li>'
}
case 'code':
return '<pre><code' + (node.lang ? ' class="language-' + esc(node.lang) + '"' : '') + '>' +
esc(node.value) + '</code></pre>'
case 'table': return '<table>' + children(node, o) + '</table>'
case 'tableRow': {
const head = node.__head === true
const cells = (node.children || []).map((c) =>
head ? '<th>' + children(c, o) + '</th>' : '<td>' + children(c, o) + '</td>').join('')
return '<tr>' + cells + '</tr>'
}
case 'tableCell': return children(node, o)
case 'footnoteReference': return '<sup class="fnref">' + esc(node.identifier) + '</sup>'
case 'footnoteDefinition': return '<div class="fn" id="fn-' + esc(node.identifier) + '">' + children(node, o) + '</div>'
case 'containerDirective':
case 'leafDirective':
case 'textDirective': return directive(node, o)
// ⛔ Raw HTML is emitted as TEXT. The dialect does not include HTML, and rendering it would hand
// markup authorship to whoever wrote the record.
case 'html': return esc(node.value)
default: return children(node, o)
}
}
/**
* render(source, { resolveBlob }) -> html
*
* `resolveBlob(oid) -> { src, mimeType } | null` is how a surface says where a `favela://blob/<oid>`
* can be read from and what it is. Omit it and every embed renders as nothing, which is correct for
* a caller that cannot serve files.
*
* TOTAL — never throws. Anything unparseable degrades to escaped text, the same posture `strip` has,
* because a surface that throws on one bad record shows nothing at all.
*/
export function render (source, opts = {}) {
const src = String(source == null ? '' : source).replace(/\r\n?/g, '\n')
let tree
try {
tree = fromMarkdown(src, MICROMARK)
} catch {
return '<p>' + esc(src) + '</p>'
}
// mdast marks the header row only by position, so tag it before the walk.
for (const node of tree.children || []) {
if (node.type === 'table' && node.children && node.children[0]) node.children[0].__head = true
}
try { return toHtml(tree, opts) } catch { return '<p>' + esc(src) + '</p>' }
}
export default render
/**
* embeds(source) -> [{ oid, alt, kind }] — every `favela://blob/<oid>` a record references.
*
* ⛔ Uses the SAME parse `render` does, not a regular expression. A regex would match a blob URI
* inside a code fence or an escaped bracket and report a reference the renderer will never make —
* and the two disagreeing about what a record references is how a surface fetches the wrong files.
*
* `kind` is the authoring gesture: `image` for `![…]()`, `link` for `[…]()`. Source order, because
* §3.5 has the embed pipeline consuming them in it.
*
* TOTAL — never throws; an unparseable record has no embeds.
*/
const labelOf = (n) => (n.children || []).map((c) => c.value || labelOf(c)).join('')
export function embeds (source) {
const src = String(source == null ? '' : source).replace(/\r\n?/g, '\n')
let tree
try { tree = fromMarkdown(src, MICROMARK) } catch { return [] }
const out = []
const walk = (node) => {
if (!node || typeof node !== 'object') return
if (node.type === 'image' || node.type === 'link') {
const oid = blobOid(node.url || '')
if (oid) {
out.push({
oid,
// ⛔ Not `textOf`, which only walks a paragraph — a link's label is its own children.
alt: node.type === 'image' ? (node.alt || '') : labelOf(node),
kind: node.type === 'image' ? 'image' : 'link'
})
}
}
for (const c of node.children || []) walk(c)
}
try { walk(tree) } catch { return out }
return out
}