Markdown Parser

A Markdown-to-HTML converter covering the everyday subset: headings, paragraphs, bullet lists, code blocks, *emphasis*, **strong** and `code`. Like most parsers, it works in two stages. It first turns the text into a tree of values describing the document, and then renders that tree. Tests can check the tree directly, without comparing HTML strings.

The document as enums

Markdown has two levels. Blocks - headings, paragraphs, lists, code - stack vertically, and Inlines - plain text, emphasis, strong, code spans - flow inside them. Each is an enum whose cases carry their content. Inline is recursive: **a *b* c** is a Strong holding text, an Emphasis, and more text. @derive(Equatable, ToString) lets the tests compare trees with != and print them in the error when they differ.

Blocks, line by line

parseBlocks moves a Cursor over the lines and lets the first line decide what comes next. The match has only guards (case _ if ...), so it reads like a chain of else-ifs. consumeWhile takes a whole run of lines at once: every - line of a list, everything up to the closing fence of a code block, or a paragraph's lines up to a blank line or the start of another block.

A heading's level is the number of #s it starts with: takeWhile keeps the leading # characters, and size() counts them.

Inlines, span by span

parseInline walks the text, collecting plain characters until a span starts. span tries one marker. It strips the opening marker, splits at the next one with splitOnce, and hands the inside to make, a lambda that builds the node. Strong and emphasis call parseInline again on their inside, which is where nesting comes from. A code span doesn't, so `*x*` stays literal.

nextSpan tries the markers in order and keeps the first that matched: code ?? strong ?? emphasis. ** is tried before *, so bold text isn't read as two empty emphases. When nothing matches - including a marker that's never closed, like the * in 2 * 3 - the character just goes into the plain text. The case (node, after) pattern takes the result apart: it matches a tuple, and the undefined from a failed match falls through to case _.

Rendering

Each case renders with #HTML. Text put into it is escaped, so a < in the Markdown can't turn into a tag. An Html value, or an array of them, is put in as markup, which is how rendered children end up inside their parent. #HTML checks each fragment is well-formed when the program compiles, so a tag's name can't come from a variable. That's why headings match on their level, with anything deeper than two drawn as <h3>.

markdownToHtml reads the file named by the first argument. ?! turns a missing argument into an error with a usage message, and each !! ends the program with the error when there is one - no argument, or a file that can't be read.

Concepts shown

  • Recursive enums as a syntax tree, compared with @derive(Equatable).
  • A Cursor with consumeWhile for line-based parsing.
  • match with guards, tuple patterns and payload patterns such as Block.Heading(1, content).
  • Lambdas passed as arguments, and ?? to take the first result that exists.
  • Safe HTML output with the #HTML macro.
  • ?! and !! for a program's error handling.