1. The Problem
Build a Markdown-to-HTML converter that takes a .md file and outputs a .html file.
Support: headings (H1–H3), bold, italic, inline code, links, unordered lists, horizontal rules, and paragraphs.
Read from stdin or a file argument; write to stdout or a file.
Why this project? Regex replacement chains, file I/O with Node's
fs module, piping (stdin/stdout), and the satisfaction of building something
you actually use as a developer.
2. How to Think About This
Parse order matters. Apply rules top-to-bottom carefully:
- Escape HTML first —
&,<,>in the source must not become actual tags. Do this before any other rule. - Block elements (headings, lists, HR) before inline elements (bold, italic)
- Inline elements before paragraphs
- Paragraphs last — wrap remaining text blocks in
<p>, then remove the wrapping around block elements
Each rule is one .replace(regex, replacement) call chained together.
The entire converter is about 20 lines of replacements.
3. The Build
import { readFileSync, writeFileSync } from "fs";
// ── The converter (pure function — no I/O) ────────────────────────────────
export function markdownToHtml(md) {
let html = md
// STEP 1: Escape HTML special chars (security — do this first!)
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
// STEP 2: Fenced code blocks ```lang ... ``` (must come before inline code)
.replace(/```[\w]*
([\s\S]*?)```/g, (_, code) =>
`<pre><code>${code.trimEnd()}</code></pre>`)
// STEP 3: Headings (must be at start of line, hence ^ with /m flag)
.replace(/^###### (.+)$/gm, "<h6>$1</h6>")
.replace(/^##### (.+)$/gm, "<h5>$1</h5>")
.replace(/^#### (.+)$/gm, "<h4>$1</h4>")
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
// STEP 4: Horizontal rule (before lists — --- could be ambiguous)
.replace(/^[-*_]{3,}$/gm, "<hr>")
// STEP 5: Unordered list items
.replace(/^[-*+] (.+)$/gm, "<li>$1</li>")
// Wrap consecutive <li> elements in <ul>
.replace(/(<li>[\s\S]*?<\/li>
?)+/g, m => `<ul>
${m}</ul>
`)
// STEP 6: Ordered list items
.replace(/^\d+\. (.+)$/gm, "<li>$1</li>")
.replace(/(<li>[\s\S]*?<\/li>
?)+/g, m =>
m.includes("<ul>") ? m : `<ol>
${m}</ol>
`)
// STEP 7: Images (must come before links — similar syntax)
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">')
// STEP 8: Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// STEP 9: Inline elements (bold before italic — ** before *)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/__(.+?)__/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/_(.+?)_/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/~~(.+?)~~/g, "<del>$1</del>")
// STEP 10: Paragraphs — wrap double-newline-separated blocks
.replace(/
{2,}/g, "
</p>
<p>");
// Wrap in <p> and clean up block-level elements that got wrapped
const BLOCK = /^<(h[1-6]|ul|ol|pre|hr|blockquote|div)/;
const lines = ("<p>" + html.trim() + "</p>").split("
");
return lines
.map(line => {
if (BLOCK.test(line.trim())) return line.replace(/^<p>/, "").replace(/<\/p>$/, "");
return line;
})
.join("
");
}
// ── HTML wrapper ───────────────────────────────────────────────────────────
export function wrapHtml(title, body) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
body { font-family: Georgia, serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; line-height: 1.7; }
code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; }
pre { background: #f5f5f5; padding: 1rem; border-radius: 6px; overflow-x: auto; }
hr { border: none; border-top: 1px solid #ddd; margin: 2rem 0; }
</style>
</head>
<body>
${body}
</body>
</html>`;
}
// ── CLI ────────────────────────────────────────────────────────────────────
const [,, inputFile, outputFile] = process.argv;
let markdown;
if (inputFile) {
markdown = readFileSync(inputFile, "utf-8");
} else {
// Read from stdin if no file given
process.stdin.setEncoding("utf-8");
let chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
markdown = chunks.join("");
}
const title = inputFile?.replace(/\.md$/, "").split("/").pop() ?? "Document";
const body = markdownToHtml(markdown);
const fullHtml = wrapHtml(title, body);
if (outputFile) {
writeFileSync(outputFile, fullHtml, "utf-8");
console.log(`Written to ${outputFile}`);
} else {
process.stdout.write(fullHtml);
}
4. Test & Prove
import { markdownToHtml } from "./md2html.mjs";
let pass=0, fail=0;
function test(desc,actual,expected){
const ok = actual === expected || actual.includes(expected);
ok ? pass++ : fail++;
console.log(ok ? " ✓" : " ✗", desc, ok ? "" : `
Got: ${JSON.stringify(actual)}
Expected: ${JSON.stringify(expected)}`);
}
test("H1 heading", markdownToHtml("# Hello"), "<h1>Hello</h1>");
test("H2 heading", markdownToHtml("## World"), "<h2>World</h2>");
test("bold **", markdownToHtml("**bold**"), "<strong>bold</strong>");
test("italic *", markdownToHtml("*italic*"), "<em>italic</em>");
test("inline code", markdownToHtml("`code`"), "<code>code</code>");
test("link", markdownToHtml("[text](https://x)"), '<a href="https://x">text</a>');
test("list item", markdownToHtml("- item"), "<li>item</li>");
test("list wrapped", markdownToHtml("- a
- b"), "<ul>");
test("HR ---", markdownToHtml("---"), "<hr>");
test("HTML escaped", markdownToHtml("<script>"), "<script>");
console.log(`
${pass} passed, ${fail} failed`);
5. The Interface
Input: a .md file (as argument) or Markdown text piped via stdin.
Output: a complete HTML file (to a named file or stdout for piping).
node md2html.mjs README.md output.html # file to file node md2html.mjs README.md # file to stdout echo "# Hello **world**" | node md2html.mjs # stdin to stdout cat blog-post.md | node md2html.mjs > blog-post.html # pipe pattern
6. Run It
node md2html.mjs README.md output.html
node md2html.test.mjs🎯 Try this next
- Add blockquote support:
> text→<blockquote> - Add a watch mode:
fs.watch()to re-convert on every save - Add a
--no-wrapflag that outputs just the body HTML without the full page shell - Extend to a static site generator: convert a folder of
.mdfiles to HTML with a shared layout template
What you practised
Chained regex replacements, parse order (block before inline, escape before transform),
fs.readFileSync/writeFileSync, async stdin iteration with for await...of,
and template literals for generating HTML strings.
Also: why you escape HTML first — security.
Reference: Regular Expressions · Strings · Async & Promises