Building md-to-html: A Minimal Markdown Converter in Shell
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:
- No dependencies. This is a core principle of my toolkit — every tool should work with just a POSIX shell and standard Unix utilities (sed, awk, grep).
- Pipe-friendly. I wanted to chain it:
cat article.md | md-to-htmland get clean HTML on stdout. - Just enough Markdown. I don't need tables, footnotes, math, or diagrams. I need: headings, code blocks, lists, links, blockquotes, bold, italic, inline code.
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:
- Detect opening
```(and optional language tag) - Accumulate lines until closing
``` - HTML-escape special characters (
<,>,&) - Output as
<pre><code>
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:
- Use awk instead of pure shell — it's better at text processing
- Support reference-style links (
[text][ref]) - Add a
--watchmode that auto-rebuilds on file changes
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.