Tier 2 — Intermediate

Markdown to HTML

Build a Markdown-to-HTML converter — regex chains, file I/O, stdin/stdout piping.

🐍 Python 🟨 JavaScript

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:

  1. Escape HTML first&, <, > in the source must not become actual tags. Do this before any other rule.
  2. Block elements (headings, lists, HR) before inline elements (bold, italic)
  3. Inline elements before paragraphs
  4. 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

md2html.mjs
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")

    // 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

md2html.test.mjs
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>"),       "&lt;script&gt;");

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

  1. Add blockquote support: > text<blockquote>
  2. Add a watch mode: fs.watch() to re-convert on every save
  3. Add a --no-wrap flag that outputs just the body HTML without the full page shell
  4. Extend to a static site generator: convert a folder of .md files 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

Try It Yourself