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.
There are dozens of Markdown converters out there. Pandoc is the standard. cmark is fast. But none of them fit my constraints:
cat article.md | md-to-html and get clean HTML on stdout.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 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 hardest part was code blocks. They need to:
``` (and optional language tag)```<, >, &)<pre><code>State machines in shell are awkward. I used a global "in_code_block" flag and a case statement.
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.
A blockquote with multiple paragraphs needs a wrapping <blockquote> around them all, not separate elements. This means tracking state across lines.
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
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.
If I were building this for production use, I'd:
[text][ref])--watch mode that auto-rebuilds on file changesBut 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.