Build a Markdown Static Site Generator
A from-scratch static site generator that reads a folder of Markdown files, renders a shared layout, resolves internal links, and emits a browsable dist directory you can host anywhere.
Project goal
Build a static site generator that turns a directory of Markdown files into a fully linked HTML site with a shared layout, per-page front matter, an index page, and copyable assets, using only the Node standard library.
Final features
- Reads every Markdown file in content/, parses front matter, and renders each to its own HTML page.
- Applies one shared layout with a title, navigation, and footer around every page body.
- Generates an index page that lists posts in reverse chronological order with excerpts.
- Rewrites relative Markdown links into correct output paths and reports broken internal links.
- Copies static assets into dist/ and cleans stale output between builds.
Architecture
A four-stage pipeline: a loader reads and parses content files into page objects, a renderer converts Markdown to HTML and injects it into the layout, an assembler writes routes to dist/ and builds the index, and a link checker validates internal targets across the whole corpus. Each stage is a pure function over page objects so it can be tested without touching disk.
Prerequisites
- Node.js 20 LTS or later
- Comfort with the Node file system APIs and async/await
- A basic understanding of Markdown structure and front matter
Dependencies
- node >=20 — File system, path handling, and the module runner
- vitest ^2.1.0 — Unit tests for the pipeline stages
Directory structure
markdown-ssg/
├── content/
│ ├── index.md
│ └── posts/
│ └── first-post.md
├── src/
│ ├── loader.js
│ ├── markdown.js
│ ├── layout.js
│ ├── build.js
│ └── links.js
├── templates/
│ └── layout.html
├── test/
│ ├── loader.test.js
│ └── build.test.js
└── package.json
Phases
1. Content loader and front matter
Recursively read Markdown files, split YAML-style front matter from the body, and normalise each file into a page object.
Files: src/loader.js
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
export async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) files.push(...(await walk(full)));
else if (e.name.endsWith('.md')) files.push(full);
}
return files;
}
export function parseFrontMatter(raw, slug) {
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/);
const meta = m ? fromYaml(m[1]) : {};
const body = m ? raw.slice(m[0].length) : raw;
return { slug, title: meta.title ?? slug, date: meta.date ?? null, body, meta };
}
export async function loadContent(dir) {
const files = await walk(dir);
const pages = [];
for (const file of files) {
const slug = relative(dir, file).replace(/\\/g, '/').replace(/\.md$/, '');
pages.push(parseFrontMatter(await readFile(file, 'utf8'), slug));
}
return pages;
}
function fromYaml(text) {
const out = {};
for (const line of text.split('\n')) {
const [k, ...rest] = line.split(':');
if (k && rest.length) out[k.trim()] = rest.join(':').trim();
}
return out;
}Expected behavior: Loader tests pass for nested directories, front matter with quoted colons, and files that omit optional fields without throwing.
2. Markdown renderer
Convert the Markdown body into HTML, covering headings, lists, code fences, emphasis, and inline links with correct href output.
Files: src/markdown.js
export function renderMarkdown(md) {
const lines = md.split('\n');
const html = [];
let inCode = false;
let inList = false;
for (const line of lines) {
if (line.startsWith('```')) {
inCode = !inCode;
html.push(inCode ? '<pre><code>' : '</code></pre>');
continue;
}
if (inCode) { html.push(escapeHtml(line)); continue; }
if (line.startsWith('# ')) { html.push(lineHtml('h1', line.slice(2))); continue; }
if (line.startsWith('## ')) { html.push(lineHtml('h2', line.slice(3))); continue; }
if (line.startsWith('- ')) {
if (!inList) { html.push('<ul>'); inList = true; }
html.push('<li>' + inline(line.slice(2)) + '</li>');
continue;
}
if (inList) { html.push('</ul>'); inList = false; }
if (line.trim()) html.push('<p>' + inline(line) + '</p>');
}
if (inList) html.push('</ul>');
return html.join('\n');
}
function inline(text) {
return escapeHtml(text).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, href) => {
return '<a href="' + href + '">' + t + '</a>';
});
}Expected behavior: Rendering a fixture document produces headings, a list, and a code block in order, and links become anchors with the original href preserved.
3. Layout and page assembly
Wrap every rendered body in the shared layout template, substitute title and navigation placeholders, and compute relative links between pages.
Files: src/layout.js
import { readFileSync } from 'node:fs';
export function loadLayout(templatePath) {
return readFileSync(templatePath, 'utf8');
}
export function renderPage(page, templatePath) {
const layout = loadLayout(templatePath);
const body = renderMarkdown(page.body);
const html = layout
.replaceAll('{{title}}', escapeHtml(page.title))
.replaceAll('{{body}}', body)
.replaceAll('{{nav}}', navFor(page));
return { slug: page.slug, html, title: page.title, date: page.date };
}
function navFor(page) {
const home = page.slug.includes('/') ? '../../index.html' : 'index.html';
return '<nav><a href="' + home + '">Home</a></nav>';
}Expected behavior: A rendered page contains exactly one html root, the page title in the head, and navigation links that resolve from the page depth.
4. Output writer and index
Write rendered pages into dist/ at paths derived from slugs, copy static assets, and generate a reverse-chronological index page.
Files: src/build.js
import { mkdir, writeFile, rm, cp } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function writeSite(outDir, rendered, pages) {
await rm(outDir, { recursive: true, force: true });
for (const page of rendered) {
const target = join(outDir, page.slug + '.html');
await mkdir(dirname(target), { recursive: true });
await writeFile(target, page.html);
}
const sorted = [...pages].sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
await writeFile(join(outDir, 'index.html'), buildIndex(sorted, rendered));
}
function buildIndex(sorted, rendered) {
const items = sorted.map((p) => '<li><a href="' + p.slug + '.html">' + p.title + '</a></li>');
return '<ul class="index">' + items.join('') + '</ul>';
}Expected behavior: A build writes one HTML file per source page plus index.html, removes stale output from a previous run, and orders the index newest-first.
5. Link integrity checker
Extract internal hrefs from every rendered page and fail the build when a target page or asset is missing from the output set.
Files: src/links.js
export function checkLinks(rendered) {
const known = new Set(rendered.map((p) => p.slug + '.html').concat(['index.html']));
const broken = [];
for (const page of rendered) {
const hrefs = [...page.html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]);
for (const href of hrefs) {
if (/^https?:|^mailto:|^#/.test(href)) continue;
const normalized = normalize(href, page.slug);
if (!known.has(normalized)) broken.push({ from: page.slug, href });
}
}
return broken;
}
function normalize(href, fromSlug) {
const parts = fromSlug.split('/');
parts.pop();
for (const seg of href.split('/')) {
if (seg === '..') parts.pop();
else if (seg !== '.') parts.push(seg);
}
return parts.join('/');
}Expected behavior: A site with one page linking to a nonexistent target reports exactly that pair, and a fully valid site reports zero broken links.
6. CLI wiring and tests
Expose the build through a small CLI, print a summary, exit non-zero on broken links, and cover the stages with unit tests.
Files: src/cli.js, test/build.test.js
import { buildSite } from './build.js';
import { loadContent } from './loader.js';
import { renderPage } from './layout.js';
const args = Object.fromEntries(
process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')),
);
const pages = await loadContent(args.content ?? 'content');
const rendered = pages.map((p) => renderPage(p, args.template ?? 'templates/layout.html'));
const broken = (await import('./links.js')).checkLinks(rendered);
if (broken.length) {
console.error('broken links:', broken.length);
process.exit(1);
}
const result = await buildSite({
contentDir: args.content ?? 'content',
outDir: args.out ?? 'dist',
templatePath: args.template ?? 'templates/layout.html',
});
console.log('wrote ' + result.pageCount + ' pages to ' + result.outDir);Expected behavior: Running node src/cli.js writes the site and prints a page count; a deliberately broken link makes the same command exit with code 1.
Testing
Run npx vitest. Loader tests cover front matter parsing, missing files, and default titles. Markdown tests cover headings, lists, code fences, and inline links. Build tests write to a temporary directory and assert that every expected file exists, that the index lists posts newest-first, and that a page with a broken internal link is reported instead of silently emitted.
Troubleshooting
Generated pages link to content.md instead of content.html
Normalise every internal link during rendering so the .md suffix is rewritten to .html before the page is written, and keep that rewrite in one helper so the checker validates the same shape the browser will follow.
Repeated builds leave stale pages in dist
Remove the whole output directory at the start of writeSite rather than writing file by file; the clean-then-write order makes the output a function of the current content instead of the history of builds.
The index lists posts in an unpredictable order
Sort by the front matter date with an explicit tie-breaker on slug, so two posts with the same day render in the same order on every machine.
Deployment
Run npm run build and deploy dist/ to any static host. Because the output is plain files with relative links, the same directory works from a subpath or a local file server without configuration changes.
Known limitations
- The renderer covers common Markdown constructs but not full CommonMark edge cases such as reference links or nested block quotes.
- There is no incremental build: every run re-renders the whole site, which is fine up to a few hundred pages.
Project overview
Static site generators sit at a useful intersection: they are simple enough to finish in a weekend and deep enough to teach file pipelines, templating, and link integrity. This project builds one from scratch so that every stage is visible, instead of hiding the interesting parts behind a plugin system.
The generator reads a content directory, renders each Markdown file through a single shared layout, assembles an index, and validates that every internal link points at a page that actually exists. Output is a plain dist/ folder with no runtime, ready for any static host.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
The whole pipeline is orchestrated by one function that loads, renders, and writes, which keeps the entry point small enough to read in a single sitting:
import { loadContent } from './loader.js';
import { renderPage } from './layout.js';
import { writeSite } from './build.js';
export async function buildSite({ contentDir, outDir, templatePath }) {
const pages = await loadContent(contentDir);
const rendered = pages.map((page) => renderPage(page, templatePath));
await writeSite(outDir, rendered, pages);
return { pageCount: pages.length, outDir };
}
Everything else in the project is a detail of one of those three calls, which is the property that keeps a growing site generator comprehensible.
Setup
This project deliberately keeps its runtime dependencies at zero so the pipeline stays legible; the only package you add is the test runner.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | File system, path handling, and the module runner |
| vitest | ^2.1.0 | Unit tests for the pipeline stages |
Create the project and install:
mkdir markdown-ssg && cd markdown-ssg
npm init -y
npm install -D vitest@^2.1.0
Configure the environment in package.json so the build and the tests have separate entry points:
{
"name": "markdown-ssg",
"type": "module",
"scripts": {
"build": "node src/cli.js",
"test": "vitest run"
}
}
Create content/, templates/, and src/ now; each later phase fills one directory, so the layout of the repository mirrors the pipeline.
Phase 1: Content loader and front matter
Recursively read Markdown files, split YAML-style front matter from the body, and normalise each file into a page object. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
export async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) files.push(...(await walk(full)));
else if (e.name.endsWith('.md')) files.push(full);
}
return files;
}
export function parseFrontMatter(raw, slug) {
const m = raw.match(/^---\n([\s\S]*?)\n---\n?/);
const meta = m ? fromYaml(m[1]) : {};
const body = m ? raw.slice(m[0].length) : raw;
return { slug, title: meta.title ?? slug, date: meta.date ?? null, body, meta };
}
export async function loadContent(dir) {
const files = await walk(dir);
const pages = [];
for (const file of files) {
const slug = relative(dir, file).replace(/\\/g, '/').replace(/\.md$/, '');
pages.push(parseFrontMatter(await readFile(file, 'utf8'), slug));
}
return pages;
}
function fromYaml(text) {
const out = {};
for (const line of text.split('\n')) {
const [k, ...rest] = line.split(':');
if (k && rest.length) out[k.trim()] = rest.join(':').trim();
}
return out;
}
Acceptance for this phase: Loader tests pass for nested directories, front matter with quoted colons, and files that omit optional fields without throwing.
Phase 2: Markdown renderer
Convert the Markdown body into HTML, covering headings, lists, code fences, emphasis, and inline links with correct href output. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function renderMarkdown(md) {
const lines = md.split('\n');
const html = [];
let inCode = false;
let inList = false;
for (const line of lines) {
if (line.startsWith('```')) {
inCode = !inCode;
html.push(inCode ? '<pre><code>' : '</code></pre>');
continue;
}
if (inCode) { html.push(escapeHtml(line)); continue; }
if (line.startsWith('# ')) { html.push(lineHtml('h1', line.slice(2))); continue; }
if (line.startsWith('## ')) { html.push(lineHtml('h2', line.slice(3))); continue; }
if (line.startsWith('- ')) {
if (!inList) { html.push('<ul>'); inList = true; }
html.push('<li>' + inline(line.slice(2)) + '</li>');
continue;
}
if (inList) { html.push('</ul>'); inList = false; }
if (line.trim()) html.push('<p>' + inline(line) + '</p>');
}
if (inList) html.push('</ul>');
return html.join('\n');
}
function inline(text) {
return escapeHtml(text).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, href) => {
return '<a href="' + href + '">' + t + '</a>';
});
}
Acceptance for this phase: Rendering a fixture document produces headings, a list, and a code block in order, and links become anchors with the original href preserved.
Phase 3: Layout and page assembly
Wrap every rendered body in the shared layout template, substitute title and navigation placeholders, and compute relative links between pages. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
import { readFileSync } from 'node:fs';
export function loadLayout(templatePath) {
return readFileSync(templatePath, 'utf8');
}
export function renderPage(page, templatePath) {
const layout = loadLayout(templatePath);
const body = renderMarkdown(page.body);
const html = layout
.replaceAll('{{title}}', escapeHtml(page.title))
.replaceAll('{{body}}', body)
.replaceAll('{{nav}}', navFor(page));
return { slug: page.slug, html, title: page.title, date: page.date };
}
function navFor(page) {
const home = page.slug.includes('/') ? '../../index.html' : 'index.html';
return '<nav><a href="' + home + '">Home</a></nav>';
}
Acceptance for this phase: A rendered page contains exactly one html root, the page title in the head, and navigation links that resolve from the page depth.
Phase 4: Output writer and index
Write rendered pages into dist/ at paths derived from slugs, copy static assets, and generate a reverse-chronological index page. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
import { mkdir, writeFile, rm, cp } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function writeSite(outDir, rendered, pages) {
await rm(outDir, { recursive: true, force: true });
for (const page of rendered) {
const target = join(outDir, page.slug + '.html');
await mkdir(dirname(target), { recursive: true });
await writeFile(target, page.html);
}
const sorted = [...pages].sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''));
await writeFile(join(outDir, 'index.html'), buildIndex(sorted, rendered));
}
function buildIndex(sorted, rendered) {
const items = sorted.map((p) => '<li><a href="' + p.slug + '.html">' + p.title + '</a></li>');
return '<ul class="index">' + items.join('') + '</ul>';
}
Acceptance for this phase: A build writes one HTML file per source page plus index.html, removes stale output from a previous run, and orders the index newest-first.
Phase 5: Link integrity checker
Extract internal hrefs from every rendered page and fail the build when a target page or asset is missing from the output set. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
export function checkLinks(rendered) {
const known = new Set(rendered.map((p) => p.slug + '.html').concat(['index.html']));
const broken = [];
for (const page of rendered) {
const hrefs = [...page.html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]);
for (const href of hrefs) {
if (/^https?:|^mailto:|^#/.test(href)) continue;
const normalized = normalize(href, page.slug);
if (!known.has(normalized)) broken.push({ from: page.slug, href });
}
}
return broken;
}
function normalize(href, fromSlug) {
const parts = fromSlug.split('/');
parts.pop();
for (const seg of href.split('/')) {
if (seg === '..') parts.pop();
else if (seg !== '.') parts.push(seg);
}
return parts.join('/');
}
Acceptance for this phase: A site with one page linking to a nonexistent target reports exactly that pair, and a fully valid site reports zero broken links.
Phase 6: CLI wiring and tests
Expose the build through a small CLI, print a summary, exit non-zero on broken links, and cover the stages with unit tests. Scoped this tightly, the phase ends with a runnable artifact and a test that proves it, so the next one starts from a known-good base.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { buildSite } from './build.js';
import { loadContent } from './loader.js';
import { renderPage } from './layout.js';
const args = Object.fromEntries(
process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')),
);
const pages = await loadContent(args.content ?? 'content');
const rendered = pages.map((p) => renderPage(p, args.template ?? 'templates/layout.html'));
const broken = (await import('./links.js')).checkLinks(rendered);
if (broken.length) {
console.error('broken links:', broken.length);
process.exit(1);
}
const result = await buildSite({
contentDir: args.content ?? 'content',
outDir: args.out ?? 'dist',
templatePath: args.template ?? 'templates/layout.html',
});
console.log('wrote ' + result.pageCount + ' pages to ' + result.outDir);
Acceptance for this phase: Running node src/cli.js writes the site and prints a page count; a deliberately broken link makes the same command exit with code 1.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Loader tests cover front matter parsing, missing files, and default titles. Markdown tests cover headings, lists, code fences, and inline links. Build tests write to a temporary directory and assert that every expected file exists, that the index lists posts newest-first, and that a page with a broken internal link is reported instead of silently emitted.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
The most valuable test is the link-integrity one: it walks every rendered page, extracts internal hrefs, and asserts each target exists in the output set, which turns a whole class of broken-navigation regressions into a single failing assertion.
Extending the project
The pipeline accepts new stages cleanly: a syntax-highlighting pass slots between Markdown and layout, an RSS generator consumes the same page objects the index uses, and a tag index is one more grouping of the same data. Because every stage is a pure function over page objects, additions are testable before they are wired in.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.