Prism

A markdown renderer in four hundred lines

Parsing markdown by hand — block scanner, inline walker, and why "safe by default" is the only defensible design.

javascriptparsingbackend

Markdown looks trivial until you try to parse it. The spec-lawyer version — CommonMark — runs to hundreds of pages of tie-breaking rules, because markdown was defined by a Perl script's behavior, not a grammar. So before writing a parser you must make one honest decision: which markdown?

Mine: the subset a technical blog actually uses, parsed predictably, with no raw HTML passthrough, ever. That last clause does more work than the other four hundred lines combined.

Two passes, no regrets

The renderer is a classic two-layer design:

  1. A block scanner walks lines, deciding what each region is — heading, fence, quote, list, table, paragraph.
  2. An inline walker runs inside each block's text, handling emphasis, links, code spans, and escapes character by character.

Blocks are easy; a line that starts with # is a heading and nothing else. The walker is where markdown bites, because inline syntax nests and overlaps:

text
**bold with `code` inside** and [a link with **bold**](/somewhere)

Regex-replacement parsers die here — they match the ** inside the code span, or the bracket inside the link label. The fix is to stop pattern-matching and walk: consume one construct at a time from the left, and when you enter a construct, recurse on its interior only.

js
if (ch === '`') {                     // code span: find the twin fence
  const close = src.indexOf(fence, i + fence.length);
  if (close !== -1) {
    out += `<code>${esc(src.slice(i + fence.length, close))}</code>`;
    i = close + fence.length;         // emphasis never sees this span
    continue;
  }
}

Because the code-span branch runs before the emphasis branch, backticks shield their contents unconditionally — the same precedence trick handles nine-tenths of markdown's ambiguity.

Safe by default

Most renderers treat raw HTML as a feature and sanitization as an add-on. That ordering is backwards. This renderer escapes everything it did not generate itself:

  • Any < in source text becomes &lt; — there is no "trusted HTML" code path to misuse.
  • URLs pass an allow-list — https:, http:, mailto:, root-relative, or fragment. A javascript: href doesn't get sanitized; it gets replaced with #.
  • Code blocks are escaped before the syntax highlighter wraps tokens in spans, so the highlighter physically cannot un-escape anything.

The comment box on this page feeds visitor text through the same pipeline the posts use. One renderer, one policy, nothing to keep in sync.

The parts I skipped

Reference-style links, setext headings, HTML blocks, four-space-indent code. In three years of notes I used none of them. Cutting features you don't use isn't laziness; it's the entire reason a 400-line parser can exist at all.

What I kept instead: tables, task lists, GitHub-style callouts, and heading anchors with slug de-duplication — the things this site does render, on every page, including this one. A parser earns its place by parsing what you write, not what the spec allows.