1. The Problem
Build a static-site generator: convert simple Markdown (headings and paragraphs) to HTML, wrap each page in a shared layout template, and auto-generate an index page linking every article by its title.
Why this project? Jekyll, Hugo, and Eleventy all do the same core job: content + template → HTML files. Building it teaches string templating, a mini Markdown parser, and how a "build step" assembles a site.
2. How to Think About This
A Markdown-to-HTML function walks each line: a # heading becomes an <h1> (and gives us the page title); any other non-blank line becomes a <p>. A layout string with {title} and {content} placeholders is filled in for every page. The build step converts each piece of content, collects titles for the index, and returns the finished pages. (Here the "files" are an in-memory object of filename → HTML — a real build writes those to disk; the logic is identical.)
3. The Build
const LAYOUT = `<!DOCTYPE html>
<html><head><title>{title}</title></head>
<body>
<nav><a href="index.html">Home</a></nav>
<main>{content}</main>
<footer>Built with my SSG</footer>
</body></html>`;
// Tiny Markdown: "# " -> <h1> (and the title), other lines -> <p>.
export function mdToHtml(text) {
let title = "Untitled";
const html = [];
for (const line of text.split("\n")) {
if (line.startsWith("# ")) {
title = line.slice(2);
html.push(`<h1>${title}</h1>`);
} else if (line.trim()) {
html.push(`<p>${line}</p>`);
}
}
return { title, body: html.join("\n") };
}
// Fill the shared layout.
export function render(title, content) {
return LAYOUT.replace("{title}", title).replace("{content}", content);
}
// Build: { filename -> markdown } becomes { filename -> html }, plus an index.
export function build(pages) {
const out = {};
const index = [];
for (const [name, md] of Object.entries(pages)) {
const { title, body } = mdToHtml(md);
const file = name.replace(/\.md$/, ".html");
out[file] = render(title, body);
index.push(`<li><a href="${file}">${title}</a></li>`);
}
out["index.html"] = render("Home", "<ul>" + index.join("") + "</ul>");
return out;
}
// Demo
const site = build({
"hello.md": "# Hello World\nMy first post.",
"about.md": "# About\nAll about me.",
});
console.log(Object.keys(site)); // [ 'hello.html', 'about.html', 'index.html' ]
console.log(site["hello.html"].includes("<h1>Hello World</h1>")); // true
4. Test & Prove
import { build, mdToHtml, render } from "./ssg.mjs";
let pass = 0, fail = 0;
function test(desc, fn) {
try { fn(); console.log(" \u2713", desc); pass++; }
catch (e) { console.log(" \u2717", desc, "\u2014", e.message); fail++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); }
test("a heading becomes an h1 and the title", () => {
const { title, body } = mdToHtml("# My Page\nsome text");
assert(title === "My Page" && body.includes("<h1>My Page</h1>"));
});
test("plain lines become paragraphs", () => {
assert(mdToHtml("# T\nhello").body.includes("<p>hello</p>"));
});
test("blank lines are skipped", () => {
assert(!mdToHtml("# T\n\n\ntext").body.includes("<p></p>"));
});
test("render drops content into the layout", () => {
const page = render("Title", "<h1>Body</h1>");
assert(page.includes("<title>Title</title>") && page.includes("<h1>Body</h1>"));
});
test("build produces a page per file plus an index", () => {
const site = build({ "a.md": "# A\nx", "b.md": "# B\ny" });
assert(site["a.html"] && site["b.html"] && site["index.html"]);
});
test("the index links every page", () => {
const site = build({ "a.md": "# Apple\nx" });
assert(site["index.html"].includes('<a href="a.html">Apple</a>'));
});
console.log(`\n${pass} passed, ${fail} failed`);
5. The Interface
node ssg.mjs [ 'hello.html', 'about.html', 'index.html' ] true
6. Run It
node ssg.mjs
node ssg.test.mjs🎯 Try this next
- Support more Markdown: bold **text**, links, and lists
- Add front-matter (a metadata block) for tags and dates
- Write the output to real files with node:fs and a content/ folder
- Add a per-tag index page grouping articles by tag