markdown

Writing a custom renderer

Walk the AST yourself and emit your own format — plain text, JSON, terminal ANSI, anything.

The built-in renderers are not magic. renderToHTML and renderToXML are pattern matches over Block and Inline. To target a different format, you write the same shape of pattern match for your output.

The walker pattern

Every Block either holds child blocks (BlockQuote, ListBlock, …) or a List[Inline] (Paragraph, Heading, …). Every Inline either holds child inlines (Emphasis, Strong, Link, …) or carries a leaf value (Text, CodeSpan, …). You walk recursively.

Example: a plain-text dumper

A renderer that emits exactly what plainText gives you for inlines, but with sensible block-level whitespace:

import io.github.edadma.markdown.*

def renderToText(node: Node): String =
  val out = new StringBuilder

  def block(b: Block): Unit = b match
    case Paragraph(inlines)              => inlines.foreach(inline); out += '\n'; out += '\n'
    case Heading(level, inlines, _)      =>
      out ++= "#" * level; out += ' '
      inlines.foreach(inline); out += '\n'; out += '\n'
    case Code(content, _, _, _)          => out ++= content; out += '\n'; out += '\n'
    case BlockQuote(children)            => children.foreach(b => { out ++= "> "; block(b) })
    case ThematicBreak()                 => out ++= "----\n\n"
    case ListBlock(data, items)          =>
      items.zipWithIndex.foreach { case (item, i) =>
        out ++= (if data.isOrdered then s"${i + 1}. " else "- ")
        item.content.foreach(block)
      }
    case HTMLBlock(c)                    => out ++= c; out += '\n'
    case _                               => () // table / callout / footnote / etc.

  def inline(i: Inline): Unit = i match
    case Text(c)                  => out ++= c
    case SoftLineBreak()          => out += ' '
    case HardLineBreak()          => out += '\n'
    case CodeSpan(c)              => out += '`'; out ++= c; out += '`'
    case Emphasis(xs)             => xs.foreach(inline)
    case Strong(xs)               => xs.foreach(inline)
    case Strikethrough(xs)        => xs.foreach(inline)
    case Link(_, _, xs)           => xs.foreach(inline)
    case Image(_, _, xs, _)       => xs.foreach(inline)
    case AutoLink(_, text)        => out ++= text
    case _                        => ()

  node match
    case Document(children) => children.foreach(block)
    case b: Block           => block(b)
    case i: Inline          => inline(i)

  out.toString

Use it:

val md = "# Title\n\nA paragraph with **bold**.\n"
val txt = renderToText(parseDocumentContent(md))
// → "# Title\n\nA paragraph with bold.\n\n"

Example: terminal ANSI

The same pattern with ANSI escape sequences instead of HTML tags. Just substitute the inline cases:

case Strong(xs)        => out ++= ""; xs.foreach(inline); out ++= ""
case Emphasis(xs)      => out ++= ""; xs.foreach(inline); out ++= ""
case CodeSpan(c)       => out ++= ""; out ++= c; out ++= ""
case Link(_, _, xs)    => out ++= ""; xs.foreach(inline); out ++= ""

Same for headings (use bright color + bold), code blocks (block-level reverse-video), thematic breaks (a row of ).

Filtering before rendering

Walking the AST also lets you transform it before rendering. Strip raw HTML for safety, demote heading levels, rewrite link destinations:

def stripRawHtml(doc: Document): Document =
  def b(block: Block): Block = block match
    case HTMLBlock(_)               => Paragraph(Nil)            // drop it
    case Paragraph(inlines)         => Paragraph(inlines.filterNot(_.isInstanceOf[RawHTML]))
    case Heading(l, inlines, a)     => Heading(l, inlines.filterNot(_.isInstanceOf[RawHTML]), a)
    case BlockQuote(children)       => BlockQuote(children.map(b))
    case ListBlock(data, items)     => ListBlock(data, items.map(it => ListItem(it.content.map(b))))
    case other                      => other
  Document(doc.children.map(b))

val safe = renderToHTML(stripRawHtml(parseDocumentContent(input)), MarkdownConfig.default)

This is not a substitute for a real HTML sanitizer — emphasis text can still contain dangerous URLs in Link destinations, etc. But it’s the right shape when your goal is “this AST is what I want, render it the normal way.”

Reusing the built-in inline renderer

If you want to keep the built-in inline rendering and only change block-level output, call renderInlines directly:

def myCustomBlock(b: Block): String = b match
  case Heading(level, inlines, _) => s"<my-h$level>${renderInlines(inlines)}</my-h$level>"
  case Paragraph(inlines)         => s"<my-p>${renderInlines(inlines)}</my-p>"
  case other                      => renderBlockToHTML(other)   // fall back

That gives you “HTML, but the wrappers I want” for free.

Reaching into processInlines

If you build Block values programmatically — e.g. you constructed a Heading(2, List(C(...)), None) from a non-markdown source — and want to re-resolve the inline content, call node.processInlines(linkRefs, config):

val raw  = Heading(2, List(/* C cursors */), None)
val done = raw.processInlines(Map.empty, MarkdownConfig.default)

This is rarely needed in practice. parseDocumentContent always returns a fully-resolved document.