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
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,"&").replace(/</g,"<").replace(/>/g,">")
.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("<")) 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
- Add tag pages — a
tags/javascript.htmllisting all posts with that tag - Add a watch mode with
fs.watch()— rebuild on every save - Add a syntax highlighter for code blocks using a CSS class approach
- 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