Tier 2 — Intermediate

Markdown Blog Generator

Convert a folder of Markdown posts into a static HTML blog — front matter, templating, index page, RSS feed.

🐍 Python 🟨 JavaScript

1. The Problem

Build a static site generator: read a posts/ directory of Markdown files with YAML front matter (title:, date:, tags:), convert each to an HTML page using a layout template, generate an index.html listing all posts, and output an RSS feed.

Why this project? File system traversal, front matter parsing, Markdown-to-HTML conversion (reusing what you built earlier), template strings for HTML generation, and RSS — the full pipeline behind every static site generator (Jekyll, Hugo, Eleventy).

2. How to Think About This

Three pipeline stages: Read — scan the posts directory, read each .md file. Transform — parse front matter (everything between --- delimiters), convert Markdown body to HTML. Write — render each post into an HTML template, write to dist/, generate index.html and feed.xml.

3. The Build

ssg.mjs
import { readdir, readFile, writeFile, mkdir } from "fs/promises";
import { join, basename } from "path";

// ── Front matter parser ────────────────────────────────────────────────────
export function parseFrontMatter(text) {
  const match = text.match(/^---
([\s\S]*?)
---
([\s\S]*)$/);
  if (!match) return { meta: {}, body: text };
  const meta = {};
  for (const line of match[1].split("
")) {
    const [key, ...vals] = line.split(":");
    if (key?.trim()) {
      let val = vals.join(":").trim();
      // Parse arrays: [tag1, tag2]
      if (val.startsWith("[") && val.endsWith("]")) {
        val = val.slice(1,-1).split(",").map(v => v.trim());
      }
      meta[key.trim()] = val;
    }
  }
  return { meta, body: match[2].trim() };
}

// ── Markdown to HTML (core rules) ─────────────────────────────────────────
export function markdownToHTML(md) {
  return md
    .replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")
    .replace(/^### (.+)$/gm,"<h3>$1</h3>").replace(/^## (.+)$/gm,"<h2>$1</h2>")
    .replace(/^# (.+)$/gm,"<h1>$1</h1>")
    .replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>").replace(/\*(.+?)\*/g,"<em>$1</em>")
    .replace(/`([^`]+)`/g,"<code>$1</code>")
    .replace(/\[([^\]]+)\]\(([^)]+)\)/g,'<a href="$2">$1</a>')
    .replace(/^[-*] (.+)$/gm,"<li>$1</li>")
    .replace(/(<li>[\s\S]*?<\/li>
?)+/g, m => `<ul>${m}</ul>`)
    .replace(/^-{3,}$/gm,"<hr>")
    .replace(/

/g,"</p><p>")
    .replace(/^(?!<[hul]|<hr)(.+)/gm, "<p>$1</p>");
}

// ── HTML template ──────────────────────────────────────────────────────────
export function postTemplate({ title, date, tags=[], content, slug }) {
  const tagHtml = tags.length
    ? `<div class="tags">${tags.map(t => `<span class="tag">${t}</span>`).join("")}</div>`
    : "";
  return `<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title}</title>
<style>body{font-family:Georgia,serif;max-width:720px;margin:2rem auto;padding:0 1rem;line-height:1.7}
h1{font-size:2rem;margin-bottom:.25rem}
.meta{color:#6b7280;font-size:.9rem;margin-bottom:2rem}
.tag{background:#f0f4ff;color:#3730a3;padding:2px 8px;border-radius:4px;font-size:.8rem;margin-right:4px}
code{background:#f5f5f5;padding:2px 5px;border-radius:3px;font-size:.9em}
a{color:#2563eb}</style>
</head><body>
<p><a href="/">← Home</a></p>
<h1>${title}</h1>
<div class="meta">${date}${tagHtml ? " · " + tagHtml : ""}</div>
${content}
</body></html>`;
}

export function indexTemplate(posts) {
  const items = posts.map(p => `
  <article>
    <h2><a href="${p.slug}.html">${p.title}</a></h2>
    <p class="meta">${p.date}</p>
    <p>${p.excerpt}</p>
  </article>`).join("");
  return `<!DOCTYPE html><html lang="en"><head>
<meta charset="UTF-8"><title>Blog</title>
<style>body{font-family:Georgia,serif;max-width:720px;margin:2rem auto;padding:0 1rem}
article{margin-bottom:2rem;border-bottom:1px solid #eee;padding-bottom:1rem}
h2{margin-bottom:.25rem} .meta{color:#6b7280;font-size:.9rem}</style>
</head><body><h1>Blog</h1>${items}</body></html>`;
}

export function rssTemplate(posts, siteUrl = "https://example.com") {
  const items = posts.map(p => `
  <item>
    <title><![CDATA[${p.title}]]></title>
    <link>${siteUrl}/${p.slug}.html</link>
    <pubDate>${new Date(p.date).toUTCString()}</pubDate>
    <description><![CDATA[${p.excerpt}]]></description>
  </item>`).join("");
  return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
  <title>Blog</title><link>${siteUrl}</link>${items}
</channel></rss>`;
}

// ── Build pipeline ─────────────────────────────────────────────────────────
const [,, postsDir = "./posts", distDir = "./dist"] = process.argv;

await mkdir(distDir, { recursive: true });

let files;
try { files = (await readdir(postsDir)).filter(f => f.endsWith(".md")); }
catch { console.error(`Could not read posts dir: ${postsDir}`); process.exit(1); }

if (!files.length) { console.log("No .md files found in", postsDir); process.exit(0); }

const posts = [];
for (const file of files) {
  const raw   = await readFile(join(postsDir, file), "utf-8");
  const { meta, body } = parseFrontMatter(raw);
  const slug    = basename(file, ".md");
  const title   = meta.title || slug;
  const date    = meta.date  || "";
  const tags    = Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []);
  const content = markdownToHTML(body);
  const excerpt = body.replace(/[#*`\[\]]/g,"").split("
").find(l => l.trim()) || "";

  await writeFile(join(distDir, `${slug}.html`), postTemplate({ title, date, tags, content, slug }));
  posts.push({ slug, title, date, tags, excerpt: excerpt.slice(0, 150) });
  console.log(`  ✓  ${slug}.html`);
}

posts.sort((a, b) => new Date(b.date) - new Date(a.date));
await writeFile(join(distDir, "index.html"), indexTemplate(posts));
await writeFile(join(distDir, "feed.xml"),   rssTemplate(posts));

console.log(`
Built ${posts.length} posts → ${distDir}/`);
console.log(`  index.html  feed.xml`);

4. Test & Prove

import { parseFrontMatter, markdownToHTML, postTemplate } from "./ssg.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log("  ✓",d);p++;}catch(e){console.log("  ✗",d,"—",e.message);f++;}}
const fm = `---
title: Hello World
date: 2026-06-27
tags: [js, web]
---

# Hello

First post.`;
const { meta, body } = parseFrontMatter(fm);
test("title parsed",         () => { if(meta.title!=="Hello World") throw new Error(meta.title); });
test("date parsed",          () => { if(meta.date!=="2026-06-27") throw new Error(); });
test("tags parsed as array", () => { if(!Array.isArray(meta.tags)||meta.tags[0]!=="js") throw new Error(JSON.stringify(meta.tags)); });
test("body extracted",       () => { if(!body.startsWith("# Hello")) throw new Error(body.slice(0,20)); });
test("markdown h1",          () => { if(!markdownToHTML("# Hello").includes("<h1>Hello</h1>")) throw new Error(); });
test("markdown bold",        () => { if(!markdownToHTML("**bold**").includes("<strong>bold</strong>")) throw new Error(); });
test("markdown link",        () => { if(!markdownToHTML("[text](url)").includes('href="url"')) throw new Error(); });
test("HTML escaping",        () => { if(!markdownToHTML("<script>").includes("&lt;")) throw new Error(); });
test("template has title",   () => { if(!postTemplate({title:"T",date:"",content:"",slug:"t"}).includes("<title>T</title>")) throw new Error(); });
console.log(`
${p} passed, ${f} failed`);

5. The Interface

# Create posts:
mkdir posts
cat > posts/hello-world.md << 'EOF'
---
title: Hello World
date: 2026-06-27
tags: [javascript, beginner]
---

# Hello World

My first blog post using the Markdown Blog Generator.
EOF

node ssg.mjs posts dist
# ✓  hello-world.html
# Built 1 posts → dist/
#   index.html  feed.xml

6. Run It

mkdir posts
# add .md files with front matter
node ssg.mjs posts dist
open dist/index.html
node ssg.test.mjs

🎯 Try this next

  1. Add tag pages — a tags/javascript.html listing all posts with that tag
  2. Add a watch mode with fs.watch() — rebuild on every save
  3. Add a syntax highlighter for code blocks using a CSS class approach
  4. Add pagination to the index — 10 posts per page with prev/next links

What you practised

Front matter parsing with regex, a full Markdown-to-HTML pipeline, template literals for HTML generation, sorting posts by date (new Date(b.date) - new Date(a.date)), writing multiple output files, and RSS feed generation — the complete static site generator pipeline.

Reference: Regular Expressions · Async & Promises · Strings

Try It Yourself