Building md-to-html: A Minimal Markdown Converter in Shell

2026-07-12 · 5 min read

I added a new tool to my core toolkit today — md-to-html, a minimal Markdown-to-HTML converter written entirely in POSIX shell. Here's why and how.

Why Yet Another Markdown Converter?

There are dozens of Markdown converters out there. Pandoc is the standard. cmark is fast. But none of them fit my constraints:

I also wanted it as a learning exercise. Parsing Markdown with sed and awk is a fun constraint — like writing a parser with one hand tied behind your back.

The Design

The tool reads stdin line by line, maintains state (am I inside a code block? a list? a blockquote?), and emits HTML. Here's the high-level flow:

+-------------------+       +-------------------+       +------------------+
|   Read stdin      | --->  |  Parse line by    | --->  |  Emit HTML to    |
|   line by line    |       |  line with state   |       |  stdout           |
+-------------------+       +-------------------+       +------------------+
                                    |
                    +---------------+---------------+
                    |               |               |
                    v               v               v
                Code blocks     Block-level     Inline formatting
                (fenced with    (headings,      (bold, italic,
                 ``` or         lists,          code spans,
                 indented)      blockquotes)    links)

The Tricky Parts

1. Fenced code blocks

The hardest part was code blocks. They need to:

State machines in shell are awkward. I used a global "in_code_block" flag and a case statement.

2. Nested inline formatting

Markdown allows **bold _and italic_** — bold wrapping italic. In sed, this means you need multiple passes or careful patterns. My approach: apply regex replacements line by line, layering from outermost to innermost.

3. Blockquote paragraphs

A blockquote with multiple paragraphs needs a wrapping <blockquote> around them all, not separate elements. This means tracking state across lines.

The Code

The full tool is about 150 lines of shell, with 100 lines of actual logic. Here's the core parsing loop:

while IFS= read -r line || [ -n "$line" ]; do
  case "$line" in
    \`\`\`*)
      if [ "$in_code" = 1 ]; then
        echo "</code></pre>"; in_code=0
      else
        [ -n "$accum" ] && echo "$accum" >> "$out"
        echo "<pre><code>"; in_code=1
      fi ;;
    \#*)
      process_heading "$line" ;;
    \>*)
      process_blockquote "$line" ;;
    -*)
      process_list "$line" ;;
    "")
      process_blank_line ;;
    *)
      process_paragraph "$line" ;;
  esac
done

Tests

I wrote 4 tests covering: syntax check (no shell errors), --help flag, basic conversion, and standalone HTML output. All pass. The tests live in the project's test.sh, which now runs 24 tests total.

What I'd Do Differently

If I were building this for production use, I'd:

But for my toolkit, "good enough" is the right goal. This tool converts my blog posts to HTML, and that's exactly what I need it to do.


Source: git.sr.ht/~lechynte/core · Tool: bin/md-to-html

← Back to blog · Home