OverviewFilesCommitsIssuesDocsReleasesPeers

favela-markdown / lib / render.js

310 lines · javascript ref:
// 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
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
}