Files
felixfoertsch.de/docs/superpowers/plans/2026-04-04-blatt.md

83 KiB

Blatt Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build Blatt — a zero-config, single-person markdown publishing server that watches a content directory and serves rendered HTML with Nunjucks templates.

Architecture: Hono HTTP server reads markdown files from a mounted content directory, parses YAML frontmatter, runs content through a unified/remark/rehype pipeline, applies Nunjucks templates, and serves the result. In-memory cache with fs.watch invalidation. Docker-first deployment.

Tech Stack: Bun (dev + Docker runtime), TypeScript (strict), Hono, unified/remark/rehype, Nunjucks, TOML config, Vitest, Biome

Spec: docs/superpowers/specs/2026-04-04-blatt-design.md


File Structure

blatt/                              # New project at ~/Developer/blatt/
├── src/
│   ├── index.ts                    # Entry point — starts Hono server
│   ├── app.ts                      # Hono app assembly, route registration
│   ├── config/
│   │   ├── schema.ts               # Zod schema for config.toml + defaults
│   │   ├── loader.ts               # TOML parsing + validation
│   │   └── config.test.ts          # Tests for config loading
│   ├── content/
│   │   ├── types.ts                # Page, CollectionPage, ContentTree types
│   │   ├── frontmatter.ts          # YAML frontmatter parsing + published field logic
│   │   ├── frontmatter.test.ts     # Tests for frontmatter parsing
│   │   ├── reader.ts               # Filesystem walker → ContentTree
│   │   ├── reader.test.ts          # Tests for content reading
│   │   ├── collection.ts           # Collection page resolution (gather + sort children)
│   │   ├── collection.test.ts      # Tests for collection logic
│   │   └── cache.ts                # In-memory content cache + fs.watch invalidation
│   ├── markdown/
│   │   ├── pipeline.ts             # unified processor assembly (all plugins)
│   │   ├── pipeline.test.ts        # Tests for markdown rendering
│   │   ├── admonitions.ts          # Custom remark-directive → callout HTML handler
│   │   └── figure.ts               # Custom rehype plugin: img → figure/figcaption
│   ├── templates/
│   │   ├── engine.ts               # Nunjucks environment setup + render()
│   │   ├── engine.test.ts          # Tests for template rendering
│   │   └── context.ts              # Assemble template variables from Page
│   ├── routes/
│   │   ├── pages.ts                # GET /* — page serving
│   │   ├── pages.test.ts           # Integration tests for page routes
│   │   ├── assets.ts               # Static file serving for co-located assets
│   │   ├── feed.ts                 # GET /feed.xml — RSS/Atom feed
│   │   ├── feed.test.ts            # Tests for feed generation
│   │   ├── sitemap.ts              # GET /sitemap.xml — sitemap
│   │   └── taxonomy.ts             # GET /tags/:tag, /category/:cat — auto index pages
│   ├── preview/
│   │   ├── middleware.ts           # Draft access control (network + token)
│   │   └── middleware.test.ts      # Tests for preview middleware
│   ├── seo/
│   │   ├── meta.ts                 # Open Graph, JSON-LD, canonical URL, reading time
│   │   └── meta.test.ts            # Tests for SEO meta generation
│   └── shared/
│       └── env.ts                  # Minimal env validation (PORT override)
├── templates/                      # Default built-in Nunjucks templates
│   ├── base.html
│   ├── default.html
│   ├── post.html
│   ├── collection.html
│   └── 404.html
├── test/
│   └── fixtures/                   # Test content directories
│       ├── basic/
│       │   └── hello/
│       │       └── index.md
│       ├── blog/
│       │   ├── index.md
│       │   ├── 2026-04-01-first/
│       │   │   ├── index.md
│       │   │   └── photo.png
│       │   └── 2026-04-02-second/
│       │       └── index.md
│       └── drafts/
│           └── wip/
│               └── index.md
├── package.json
├── tsconfig.json
├── biome.json
├── .mise.toml
├── Dockerfile
├── docker-compose.yml
├── .gitignore
└── config.example.toml

Task 1: Project Scaffold

Files:

  • Create: ~/Developer/blatt/package.json

  • Create: ~/Developer/blatt/tsconfig.json

  • Create: ~/Developer/blatt/biome.json

  • Create: ~/Developer/blatt/.mise.toml

  • Create: ~/Developer/blatt/.gitignore

  • Create: ~/Developer/blatt/src/index.ts

  • Create: ~/Developer/blatt/src/app.ts

  • Step 1: Create project directory and initialize git

mkdir -p ~/Developer/blatt
cd ~/Developer/blatt
git init
  • Step 2: Create .mise.toml
[tools]
bun = "1.2.9"
node = "22.14.0"
cd ~/Developer/blatt
mise install
  • Step 3: Initialize package.json with dependencies
cd ~/Developer/blatt
bun init -y

Then replace package.json with:

{
	"name": "blatt",
	"version": "2026.04.04",
	"type": "module",
	"scripts": {
		"dev": "bun --watch src/index.ts",
		"start": "bun src/index.ts",
		"test": "vitest run",
		"test:watch": "vitest",
		"lint": "biome check src/",
		"format": "biome check --write src/"
	},
	"dependencies": {
		"@hono/node-server": "^1.14.0",
		"hono": "^4.7.0",
		"smol-toml": "^1.3.0",
		"zod": "^3.24.0",
		"gray-matter": "^4.0.3",
		"nunjucks": "^3.2.4",
		"unified": "^11.0.5",
		"remark-parse": "^11.0.0",
		"remark-gfm": "^4.0.1",
		"remark-math": "^6.0.0",
		"remark-smartypants": "^3.0.2",
		"remark-directive": "^4.0.0",
		"remark-definition-list": "^2.0.0",
		"remark-rehype": "^11.1.2",
		"rehype-slug": "^6.0.0",
		"rehype-autolink-headings": "^7.1.0",
		"rehype-external-links": "^3.0.0",
		"rehype-katex": "^7.0.1",
		"@shikijs/rehype": "^3.3.0",
		"rehype-stringify": "^10.0.1",
		"feed": "^4.2.2",
		"fast-xml-parser": "^4.5.0"
	},
	"devDependencies": {
		"@biomejs/biome": "^1.9.0",
		"@types/nunjucks": "^3.2.6",
		"typescript": "^5.8.0",
		"vitest": "^3.1.0"
	}
}
cd ~/Developer/blatt
bun install
  • Step 4: Create tsconfig.json
{
	"compilerOptions": {
		"strict": true,
		"target": "ES2022",
		"module": "ES2022",
		"moduleResolution": "bundler",
		"esModuleInterop": true,
		"skipLibCheck": true,
		"forceConsistentCasingInFileNames": true,
		"resolveJsonModule": true,
		"declaration": true,
		"outDir": "dist",
		"rootDir": "src",
		"types": ["bun-types"]
	},
	"include": ["src/**/*.ts"],
	"exclude": ["node_modules", "dist"]
}
  • Step 5: Create biome.json
{
	"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
	"organizeImports": { "enabled": true },
	"formatter": {
		"indentStyle": "tab",
		"lineWidth": 100
	},
	"linter": {
		"enabled": true,
		"rules": { "recommended": true }
	}
}
  • Step 6: Create .gitignore
node_modules/
dist/
.env
.env.local
.env.*.local
.mise.local.toml
  • Step 7: Create minimal src/app.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => c.text("Blatt is running"));

export default app;
  • Step 8: Create src/index.ts
import app from "./app.js";

const port = Number(process.env.PORT) || 3000;

console.log(`Blatt listening on http://localhost:${port}`);

export default {
	port,
	fetch: app.fetch,
};
  • Step 9: Verify the server starts
cd ~/Developer/blatt
bun src/index.ts &
sleep 1
curl -s http://localhost:3000
# Expected: "Blatt is running"
kill %1
  • Step 10: Commit
cd ~/Developer/blatt
git add -A
git commit -m "scaffold project with hono, typescript, biome"

Task 2: Config Loading

Files:

  • Create: src/config/schema.ts

  • Create: src/config/loader.ts

  • Create: src/config/config.test.ts

  • Create: config.example.toml

  • Step 1: Write failing tests for config loading

src/config/config.test.ts:

import { describe, expect, it } from "vitest";
import { parseConfig, defaultConfig } from "./loader.js";

describe("config loading", () => {
	it("returns defaults when no config file exists", () => {
		const config = parseConfig(undefined);
		expect(config.site.name).toBe("Blatt");
		expect(config.server.port).toBe(3000);
		expect(config.markdown.math).toBe(true);
		expect(config.feed.enabled).toBe(true);
		expect(config.sitemap.enabled).toBe(true);
	});

	it("parses a valid TOML string", () => {
		const toml = `
[site]
name = "My Site"
url = "https://example.com"

[server]
port = 4000
trusted_networks = ["10.0.0.0/8"]
preview_token = "secret123"

[markdown]
math = false
`;
		const config = parseConfig(toml);
		expect(config.site.name).toBe("My Site");
		expect(config.site.url).toBe("https://example.com");
		expect(config.server.port).toBe(4000);
		expect(config.server.trusted_networks).toEqual(["10.0.0.0/8"]);
		expect(config.server.preview_token).toBe("secret123");
		expect(config.markdown.math).toBe(false);
		// other markdown features still default to true
		expect(config.markdown.footnotes).toBe(true);
	});

	it("merges partial config with defaults", () => {
		const toml = `
[site]
name = "Partial"
`;
		const config = parseConfig(toml);
		expect(config.site.name).toBe("Partial");
		expect(config.site.language).toBe("en");
		expect(config.server.port).toBe(3000);
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/config/config.test.ts

Expected: FAIL — modules don't exist yet.

  • Step 3: Implement config schema

src/config/schema.ts:

import { z } from "zod";

export const siteSchema = z.object({
	name: z.string().default("Blatt"),
	url: z.string().default("http://localhost:3000"),
	language: z.string().default("en"),
	description: z.string().default(""),
	author: z.string().default(""),
});

export const serverSchema = z.object({
	port: z.number().default(3000),
	trusted_networks: z.array(z.string()).default([]),
	preview_token: z.string().default(""),
});

export const markdownSchema = z.object({
	syntax_highlighting: z.boolean().default(true),
	footnotes: z.boolean().default(true),
	smart_typography: z.boolean().default(true),
	heading_anchors: z.boolean().default(true),
	figure_captions: z.boolean().default(true),
	external_links: z.boolean().default(true),
	toc: z.boolean().default(true),
	math: z.boolean().default(true),
	admonitions: z.boolean().default(true),
	definition_lists: z.boolean().default(true),
	task_lists: z.boolean().default(true),
	mermaid: z.boolean().default(true),
	abbreviations: z.boolean().default(true),
	superscript_subscript: z.boolean().default(true),
	highlight_mark: z.boolean().default(true),
});

export const feedSchema = z.object({
	enabled: z.boolean().default(true),
	path: z.string().default("/feed.xml"),
});

export const sitemapSchema = z.object({
	enabled: z.boolean().default(true),
	path: z.string().default("/sitemap.xml"),
});

export const configSchema = z.object({
	site: siteSchema.default({}),
	server: serverSchema.default({}),
	markdown: markdownSchema.default({}),
	feed: feedSchema.default({}),
	sitemap: sitemapSchema.default({}),
});

export type BlattConfig = z.infer<typeof configSchema>;
  • Step 4: Implement config loader

src/config/loader.ts:

import { parse as parseToml } from "smol-toml";
import { configSchema, type BlattConfig } from "./schema.js";

export { type BlattConfig } from "./schema.js";

export const defaultConfig: BlattConfig = configSchema.parse({});

export function parseConfig(tomlString: string | undefined): BlattConfig {
	if (!tomlString) {
		return defaultConfig;
	}
	const raw = parseToml(tomlString);
	return configSchema.parse(raw);
}

export function loadConfigFile(path: string): BlattConfig {
	try {
		const content = Bun.file(path);
		// Bun.file returns a lazy reference — we need to read it synchronously
		const text = require("node:fs").readFileSync(path, "utf-8");
		return parseConfig(text);
	} catch {
		return defaultConfig;
	}
}
  • Step 5: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/config/config.test.ts

Expected: all 3 tests PASS.

  • Step 6: Create config.example.toml
[site]
name = "My Site"
url = "https://example.com"
language = "en"
description = "A site powered by Blatt"
author = "Your Name"

[server]
port = 3000
# CIDRs that can see draft content without a token
trusted_networks = ["192.168.0.0/16"]
# Token for remote draft preview (?preview=<token>)
preview_token = ""

[markdown]
syntax_highlighting = true
footnotes = true
smart_typography = true
heading_anchors = true
figure_captions = true
external_links = true
toc = true
math = true
admonitions = true
definition_lists = true
task_lists = true
mermaid = true
abbreviations = true
superscript_subscript = true
highlight_mark = true

[feed]
enabled = true
path = "/feed.xml"

[sitemap]
enabled = true
path = "/sitemap.xml"
  • Step 7: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add config loading with toml parsing, zod validation, sensible defaults"

Task 3: Page Types and Frontmatter Parsing

Files:

  • Create: src/content/types.ts

  • Create: src/content/frontmatter.ts

  • Create: src/content/frontmatter.test.ts

  • Step 1: Write failing tests for frontmatter parsing

src/content/frontmatter.test.ts:

import { describe, expect, it } from "vitest";
import { parseFrontmatter, parsePublished } from "./frontmatter.js";

describe("parsePublished", () => {
	it("returns draft for false", () => {
		const result = parsePublished(false);
		expect(result).toEqual({ draft: true, publishedDate: null, updatedDate: null });
	});

	it("returns published with no date for true", () => {
		const result = parsePublished(true);
		expect(result).toEqual({ draft: false, publishedDate: null, updatedDate: null });
	});

	it("returns published with no date when undefined", () => {
		const result = parsePublished(undefined);
		expect(result).toEqual({ draft: false, publishedDate: null, updatedDate: null });
	});

	it("parses a single ISO date", () => {
		const result = parsePublished("2026-04-04");
		expect(result).toEqual({
			draft: false,
			publishedDate: "2026-04-04",
			updatedDate: null,
		});
	});

	it("parses two ISO dates (published, updated)", () => {
		const result = parsePublished("2026-04-04, 2026-04-10");
		expect(result).toEqual({
			draft: false,
			publishedDate: "2026-04-04",
			updatedDate: "2026-04-10",
		});
	});

	it("parses ISO datetime", () => {
		const result = parsePublished("2026-04-04 14:30");
		expect(result).toEqual({
			draft: false,
			publishedDate: "2026-04-04 14:30",
			updatedDate: null,
		});
	});
});

describe("parseFrontmatter", () => {
	it("parses full frontmatter", () => {
		const md = `---
title: My Post
description: A great post
published: 2026-04-04
template: post
taxonomy:
  tags: [math, cs]
  category: [tutorials]
---

# Hello World`;

		const result = parseFrontmatter(md);
		expect(result.data.title).toBe("My Post");
		expect(result.data.description).toBe("A great post");
		expect(result.data.published).toBe("2026-04-04");
		expect(result.data.template).toBe("post");
		expect(result.data.taxonomy.tags).toEqual(["math", "cs"]);
		expect(result.content).toContain("# Hello World");
	});

	it("returns empty data when no frontmatter", () => {
		const md = "# Just Markdown";
		const result = parseFrontmatter(md);
		expect(result.data).toEqual({});
		expect(result.content).toContain("# Just Markdown");
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/content/frontmatter.test.ts

Expected: FAIL.

  • Step 3: Implement page types

src/content/types.ts:

export interface PublishedInfo {
	draft: boolean;
	publishedDate: string | null;
	updatedDate: string | null;
}

export interface PageMeta {
	title: string;
	description: string;
	template: string;
	published: PublishedInfo;
	taxonomy: {
		tags: string[];
		category: string[];
	};
	// raw frontmatter for arbitrary template access
	raw: Record<string, unknown>;
}

export interface Page {
	/** URL path, e.g. "/blog/2026-04-04-pumping-lemma" */
	slug: string;
	/** Absolute filesystem path to the page folder */
	dirPath: string;
	/** Absolute filesystem path to index.md */
	filePath: string;
	/** Parsed frontmatter metadata */
	meta: PageMeta;
	/** Raw markdown content (without frontmatter) */
	markdown: string;
	/** Rendered HTML content */
	html: string;
}

export interface CollectConfig {
	from: string;
	sort: string;
	order: "asc" | "desc";
}

export interface CollectionPage extends Page {
	collect: CollectConfig;
	children: Page[];
}

export function isCollectionPage(page: Page): page is CollectionPage {
	return "collect" in page;
}
  • Step 4: Implement frontmatter parsing

src/content/frontmatter.ts:

import matter from "gray-matter";
import type { PublishedInfo } from "./types.js";

export interface FrontmatterResult {
	data: Record<string, unknown>;
	content: string;
}

export function parseFrontmatter(raw: string): FrontmatterResult {
	const { data, content } = matter(raw);
	return { data: data as Record<string, unknown>, content };
}

export function parsePublished(
	value: unknown,
): PublishedInfo {
	if (value === false) {
		return { draft: true, publishedDate: null, updatedDate: null };
	}

	if (value === true || value === undefined || value === null) {
		return { draft: false, publishedDate: null, updatedDate: null };
	}

	if (typeof value === "string") {
		const parts = value.split(",").map((s) => s.trim());
		return {
			draft: false,
			publishedDate: parts[0] || null,
			updatedDate: parts[1] || null,
		};
	}

	// gray-matter auto-parses dates to Date objects — convert back to ISO string
	if (value instanceof Date) {
		const iso = value.toISOString().split("T")[0];
		return { draft: false, publishedDate: iso, updatedDate: null };
	}

	return { draft: false, publishedDate: null, updatedDate: null };
}
  • Step 5: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/content/frontmatter.test.ts

Expected: all tests PASS. Note: gray-matter may auto-parse published: 2026-04-04 as a Date object. If the string test fails because of this, update the frontmatter parsing to handle Date objects by converting them to ISO date strings, and add published: "2026-04-04" (quoted) in the test fixture to keep the string path tested separately.

  • Step 6: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add page types, frontmatter parsing with published field logic"

Task 4: Content Reader

Files:

  • Create: src/content/reader.ts

  • Create: src/content/reader.test.ts

  • Create: test/fixtures/basic/hello/index.md

  • Create: test/fixtures/blog/index.md

  • Create: test/fixtures/blog/2026-04-01-first/index.md

  • Create: test/fixtures/blog/2026-04-02-second/index.md

  • Create: test/fixtures/drafts/wip/index.md

  • Step 1: Create test fixtures

test/fixtures/basic/hello/index.md:

---
title: Hello World
published: 2026-04-01
---

# Hello World

This is a test page.

test/fixtures/blog/index.md:

---
title: Blog
template: collection
collect:
  from: .
  sort: published
  order: desc
---

test/fixtures/blog/2026-04-01-first/index.md:

---
title: First Post
published: 2026-04-01
template: post
taxonomy:
  tags: [intro]
---

# First Post

Content here.

test/fixtures/blog/2026-04-02-second/index.md:

---
title: Second Post
published: 2026-04-02
template: post
taxonomy:
  tags: [update]
---

# Second Post

More content.

test/fixtures/drafts/wip/index.md:

---
title: Work In Progress
published: false
---

# Draft

Not ready yet.
  • Step 2: Write failing tests for content reader

src/content/reader.test.ts:

import { describe, expect, it } from "vitest";
import { readContentTree } from "./reader.js";
import path from "node:path";

const fixturesDir = path.resolve(import.meta.dirname, "../../test/fixtures");

describe("readContentTree", () => {
	it("reads a simple page", () => {
		const pages = readContentTree(path.join(fixturesDir, "basic"));
		expect(pages).toHaveLength(1);
		expect(pages[0].slug).toBe("/hello");
		expect(pages[0].meta.title).toBe("Hello World");
		expect(pages[0].meta.published.publishedDate).toBe("2026-04-01");
		expect(pages[0].markdown).toContain("# Hello World");
	});

	it("reads nested blog structure", () => {
		const pages = readContentTree(path.join(fixturesDir, "blog"));
		// 3 pages: the blog index + 2 posts
		expect(pages).toHaveLength(3);

		const slugs = pages.map((p) => p.slug).sort();
		expect(slugs).toEqual(["/", "/2026-04-01-first", "/2026-04-02-second"]);
	});

	it("marks draft pages", () => {
		const pages = readContentTree(path.join(fixturesDir, "drafts"));
		expect(pages).toHaveLength(1);
		expect(pages[0].meta.published.draft).toBe(true);
	});

	it("detects collection pages", () => {
		const pages = readContentTree(path.join(fixturesDir, "blog"));
		const index = pages.find((p) => p.slug === "/");
		expect(index).toBeDefined();
		expect(index!.meta.template).toBe("collection");
		expect((index!.meta.raw as Record<string, unknown>).collect).toBeDefined();
	});
});
  • Step 3: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/content/reader.test.ts

Expected: FAIL.

  • Step 4: Implement content reader

src/content/reader.ts:

import fs from "node:fs";
import path from "node:path";
import { parseFrontmatter, parsePublished } from "./frontmatter.js";
import type { Page, PageMeta } from "./types.js";

function buildPageMeta(data: Record<string, unknown>): PageMeta {
	const taxonomy = (data.taxonomy as Record<string, string[]>) ?? {};
	return {
		title: (data.title as string) ?? "",
		description: (data.description as string) ?? "",
		template: (data.template as string) ?? "default",
		published: parsePublished(data.published),
		taxonomy: {
			tags: taxonomy.tags ?? [],
			category: taxonomy.category ?? [],
		},
		raw: data,
	};
}

export function readContentTree(contentDir: string, baseSlug = ""): Page[] {
	const pages: Page[] = [];
	const entries = fs.readdirSync(contentDir, { withFileTypes: true });

	// check for index.md in this directory
	const indexFile = path.join(contentDir, "index.md");
	if (fs.existsSync(indexFile)) {
		const raw = fs.readFileSync(indexFile, "utf-8");
		const { data, content } = parseFrontmatter(raw);
		const slug = baseSlug || "/";

		pages.push({
			slug,
			dirPath: contentDir,
			filePath: indexFile,
			meta: buildPageMeta(data),
			markdown: content,
			html: "", // filled later by markdown pipeline
		});
	}

	// recurse into subdirectories
	for (const entry of entries) {
		if (!entry.isDirectory()) continue;
		if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;

		const subDir = path.join(contentDir, entry.name);
		const subSlug = `${baseSlug}/${entry.name}`;
		const subPages = readContentTree(subDir, subSlug);
		pages.push(...subPages);
	}

	return pages;
}
  • Step 5: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/content/reader.test.ts

Expected: all tests PASS.

  • Step 6: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add content reader, walk filesystem to build page tree"

Task 5: Markdown Pipeline

Files:

  • Create: src/markdown/pipeline.ts

  • Create: src/markdown/admonitions.ts

  • Create: src/markdown/figure.ts

  • Create: src/markdown/pipeline.test.ts

  • Step 1: Write failing tests for markdown rendering

src/markdown/pipeline.test.ts:

import { describe, expect, it } from "vitest";
import { createMarkdownPipeline } from "./pipeline.js";
import { defaultConfig } from "../config/loader.js";

describe("markdown pipeline", () => {
	const pipeline = createMarkdownPipeline(defaultConfig.markdown);

	it("renders basic markdown to HTML", async () => {
		const result = await pipeline.render("# Hello\n\nA paragraph.");
		expect(result.html).toContain("<h1");
		expect(result.html).toContain("Hello");
		expect(result.html).toContain("<p>A paragraph.</p>");
	});

	it("renders GFM tables", async () => {
		const md = "| A | B |\n|---|---|\n| 1 | 2 |";
		const result = await pipeline.render(md);
		expect(result.html).toContain("<table>");
		expect(result.html).toContain("<td>1</td>");
	});

	it("renders footnotes", async () => {
		const md = "Text with a footnote[^1].\n\n[^1]: Footnote content.";
		const result = await pipeline.render(md);
		expect(result.html).toContain("footnote");
	});

	it("renders math with KaTeX", async () => {
		const md = "Inline $E = mc^2$ and block:\n\n$$\\int_0^1 x^2 dx$$";
		const result = await pipeline.render(md);
		expect(result.html).toContain("katex");
	});

	it("renders fenced code with syntax highlighting", async () => {
		const md = '```js\nconst x = 1;\n```';
		const result = await pipeline.render(md);
		// Shiki wraps code in a <pre> with data-language or class
		expect(result.html).toContain("<pre");
		expect(result.html).toContain("const");
	});

	it("adds heading IDs and anchor links", async () => {
		const md = "## My Section";
		const result = await pipeline.render(md);
		expect(result.html).toContain('id="my-section"');
	});

	it("decorates external links", async () => {
		const md = "[Example](https://example.com)";
		const result = await pipeline.render(md);
		expect(result.html).toContain('target="_blank"');
		expect(result.html).toContain("noopener");
	});

	it("renders admonitions", async () => {
		const md = ":::note\nThis is a note.\n:::";
		const result = await pipeline.render(md);
		expect(result.html).toContain("callout");
		expect(result.html).toContain("note");
	});

	it("generates table of contents", async () => {
		const md = "## Table of Contents\n\n## First\n\n## Second";
		const result = await pipeline.render(md);
		expect(result.html).toContain('href="#first"');
		expect(result.html).toContain('href="#second"');
	});

	it("renders smart typography", async () => {
		const md = '"Hello" -- world...';
		const result = await pipeline.render(md);
		expect(result.html).toContain("\u201C"); // left double quote
		expect(result.html).toContain("\u2013"); // en-dash
		expect(result.html).toContain("\u2026"); // ellipsis
	});

	it("extracts TOC data", async () => {
		const md = "## First Heading\n\n### Sub Heading\n\n## Second Heading";
		const result = await pipeline.render(md);
		expect(result.toc).toHaveLength(3);
		expect(result.toc[0]).toMatchObject({ text: "First Heading", depth: 2 });
		expect(result.toc[1]).toMatchObject({ text: "Sub Heading", depth: 3 });
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/markdown/pipeline.test.ts

Expected: FAIL.

  • Step 3: Implement custom admonitions plugin

src/markdown/admonitions.ts:

import type { Root } from "mdast";
import { visit } from "unist-util-visit";

// Transforms remark-directive container directives (:::note, :::warning, etc.)
// into hast-compatible nodes that render as callout blocks.
export function remarkAdmonitions() {
	return (tree: Root) => {
		visit(tree, (node) => {
			if (node.type !== "containerDirective") return;

			const directive = node as Record<string, unknown>;
			const name = directive.name as string;
			const validTypes = ["note", "tip", "warning", "danger", "info"];

			if (!validTypes.includes(name)) return;

			const data = (directive.data ?? {}) as Record<string, unknown>;
			directive.data = data;

			data.hName = "aside";
			data.hProperties = {
				class: `callout callout-${name}`,
				role: "note",
			};
		});
	};
}
  • Step 4: Implement custom figure plugin

src/markdown/figure.ts:

import type { Root, Element } from "hast";
import { visit } from "unist-util-visit";

// Promotes standalone <img> elements (sole child of a <p>) to <figure> with <figcaption>.
export function rehypeFigure() {
	return (tree: Root) => {
		visit(tree, "element", (node: Element, index, parent) => {
			if (node.tagName !== "p" || !parent || index === undefined) return;

			// check if <p> contains exactly one <img>
			const children = node.children.filter(
				(c) => !(c.type === "text" && c.value.trim() === ""),
			);
			if (children.length !== 1) return;

			const child = children[0];
			if (child.type !== "element" || child.tagName !== "img") return;

			const alt = (child.properties?.alt as string) ?? "";
			if (!alt) return;

			const figure: Element = {
				type: "element",
				tagName: "figure",
				properties: {},
				children: [
					child,
					{
						type: "element",
						tagName: "figcaption",
						properties: {},
						children: [{ type: "text", value: alt }],
					},
				],
			};

			parent.children[index] = figure;
		});
	};
}
  • Step 5: Implement the markdown pipeline

src/markdown/pipeline.ts:

import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkSmartypants from "remark-smartypants";
import remarkDirective from "remark-directive";
import remarkDefinitionList from "remark-definition-list";
import remarkRehype from "remark-rehype";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypeExternalLinks from "rehype-external-links";
import rehypeKatex from "rehype-katex";
import rehypeShiki from "@shikijs/rehype";
import rehypeStringify from "rehype-stringify";
import { remarkAdmonitions } from "./admonitions.js";
import { rehypeFigure } from "./figure.js";
import type { BlattConfig } from "../config/schema.js";
import type { Root } from "hast";
import { visit } from "unist-util-visit";

export interface TocEntry {
	text: string;
	depth: number;
	id: string;
}

export interface RenderResult {
	html: string;
	toc: TocEntry[];
}

function extractToc(): { plugin: () => (tree: Root) => void; entries: TocEntry[] } {
	const entries: TocEntry[] = [];
	const plugin = () => (tree: Root) => {
		visit(tree, "element", (node) => {
			const el = node as import("hast").Element;
			const match = el.tagName.match(/^h([2-6])$/);
			if (!match) return;

			const depth = Number.parseInt(match[1], 10);
			const id = (el.properties?.id as string) ?? "";
			let text = "";

			visit(el, "text", (textNode) => {
				text += (textNode as import("hast").Text).value;
			});

			entries.push({ text: text.trim(), depth, id });
		});
	};
	return { plugin, entries };
}

export function createMarkdownPipeline(config: BlattConfig["markdown"]) {
	return {
		async render(markdown: string): Promise<RenderResult> {
			const tocExtractor = extractToc();

			let processor = unified().use(remarkParse);

			// remark plugins
			if (config.footnotes || config.task_lists) {
				processor = processor.use(remarkGfm);
			}
			if (config.math) {
				processor = processor.use(remarkMath);
			}
			if (config.smart_typography) {
				processor = processor.use(remarkSmartypants);
			}
			if (config.admonitions) {
				processor = processor.use(remarkDirective).use(remarkAdmonitions);
			}
			if (config.definition_lists) {
				processor = processor.use(remarkDefinitionList);
			}

			// bridge to rehype
			processor = processor.use(remarkRehype, { allowDangerousHtml: true });

			// rehype plugins
			if (config.heading_anchors) {
				processor = processor
					.use(rehypeSlug)
					.use(rehypeAutolinkHeadings, { behavior: "wrap" });
			}
			if (config.external_links) {
				processor = processor.use(rehypeExternalLinks, {
					target: "_blank",
					rel: ["noopener", "noreferrer"],
				});
			}
			if (config.math) {
				processor = processor.use(rehypeKatex);
			}
			if (config.syntax_highlighting) {
				processor = processor.use(rehypeShiki, {
					theme: "github-dark",
				});
			}
			if (config.figure_captions) {
				processor = processor.use(rehypeFigure);
			}

			// TOC extraction (always runs — the toc data is available in templates)
			processor = processor.use(tocExtractor.plugin);

			processor = processor.use(rehypeStringify, { allowDangerousHtml: true });

			const file = await processor.process(markdown);
			return {
				html: String(file),
				toc: tocExtractor.entries,
			};
		},
	};
}
  • Step 6: Install missing dependency unist-util-visit
cd ~/Developer/blatt
bun add unist-util-visit
  • Step 7: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/markdown/pipeline.test.ts

Expected: all tests PASS. Some tests may need adjustment depending on exact plugin output format — fix as needed.

  • Step 8: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add markdown pipeline with remark/rehype, all plugins, toc extraction"

Task 6: Nunjucks Template Engine

Files:

  • Create: src/templates/engine.ts

  • Create: src/templates/context.ts

  • Create: src/templates/engine.test.ts

  • Create: templates/base.html

  • Create: templates/default.html

  • Create: templates/post.html

  • Create: templates/collection.html

  • Create: templates/404.html

  • Step 1: Write failing tests for template engine

src/templates/engine.test.ts:

import { describe, expect, it } from "vitest";
import { createTemplateEngine } from "./engine.js";
import path from "node:path";

const templatesDir = path.resolve(import.meta.dirname, "../../templates");

describe("template engine", () => {
	const engine = createTemplateEngine(templatesDir);

	it("renders the default template with content", () => {
		const html = engine.render("default", {
			title: "Test Page",
			content: "<p>Hello world</p>",
			config: { site: { name: "Test" } },
		});
		expect(html).toContain("Test Page");
		expect(html).toContain("<p>Hello world</p>");
		expect(html).toContain("<!DOCTYPE html>");
	});

	it("renders the post template with date and reading time", () => {
		const html = engine.render("post", {
			title: "My Post",
			content: "<p>Content</p>",
			published_date: "2026-04-04",
			reading_time: "2 min read",
			config: { site: { name: "Test" } },
		});
		expect(html).toContain("My Post");
		expect(html).toContain("2026-04-04");
		expect(html).toContain("2 min read");
	});

	it("renders the collection template with page list", () => {
		const html = engine.render("collection", {
			title: "Blog",
			content: "",
			pages: [
				{ title: "Post A", slug: "/blog/a", published_date: "2026-04-01" },
				{ title: "Post B", slug: "/blog/b", published_date: "2026-04-02" },
			],
			config: { site: { name: "Test" } },
		});
		expect(html).toContain("Post A");
		expect(html).toContain("Post B");
		expect(html).toContain("/blog/a");
	});

	it("falls back to default template for unknown names", () => {
		const html = engine.render("nonexistent", {
			title: "Fallback",
			content: "<p>Works</p>",
			config: { site: { name: "Test" } },
		});
		expect(html).toContain("Fallback");
		expect(html).toContain("<!DOCTYPE html>");
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/templates/engine.test.ts

Expected: FAIL.

  • Step 3: Create default templates

templates/base.html:

<!DOCTYPE html>
<html lang="{{ config.site.language | default('en') }}">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>{% block title %}{{ title }}{% endblock %} — {{ config.site.name | default('Blatt') }}</title>
	{% if description %}<meta name="description" content="{{ description }}">{% endif %}
	{{ meta_tags | safe }}
	<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css">
	{% block head %}{% endblock %}
	<style>
		:root {
			--max-width: 48rem;
			--font-sans: system-ui, -apple-system, sans-serif;
			--font-mono: ui-monospace, "Cascadia Code", "Fira Code", monospace;
		}
		* { margin: 0; padding: 0; box-sizing: border-box; }
		body {
			font-family: var(--font-sans);
			line-height: 1.6;
			color: #1a1a1a;
			max-width: var(--max-width);
			margin: 0 auto;
			padding: 2rem 1rem;
		}
		a { color: #0066cc; }
		a:hover { color: #004499; }
		pre { padding: 1rem; border-radius: 0.375rem; overflow-x: auto; margin: 1rem 0; }
		code { font-family: var(--font-mono); font-size: 0.9em; }
		img { max-width: 100%; height: auto; }
		figure { margin: 1.5rem 0; }
		figcaption { font-size: 0.875rem; color: #666; margin-top: 0.5rem; }
		table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
		th, td { border: 1px solid #ddd; padding: 0.5rem; text-align: left; }
		th { background: #f5f5f5; }
		blockquote { border-left: 3px solid #ddd; padding-left: 1rem; color: #555; margin: 1rem 0; }
		.callout { padding: 1rem; border-left: 4px solid; margin: 1rem 0; border-radius: 0 0.25rem 0.25rem 0; }
		.callout-note { border-color: #0066cc; background: #f0f7ff; }
		.callout-tip { border-color: #00a854; background: #f0fff4; }
		.callout-warning { border-color: #faad14; background: #fffbe6; }
		.callout-danger { border-color: #f5222d; background: #fff1f0; }
		.callout-info { border-color: #0066cc; background: #f0f7ff; }
		nav { margin-bottom: 2rem; }
		nav a { margin-right: 1rem; }
		.draft-banner { background: #faad14; color: #1a1a1a; padding: 0.5rem 1rem; text-align: center; font-size: 0.875rem; margin-bottom: 1rem; border-radius: 0.25rem; }
		footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #eee; font-size: 0.875rem; color: #888; }
	</style>
</head>
<body>
	{% if draft_banner %}<div class="draft-banner">Draft — not publicly visible</div>{% endif %}
	{% block body %}{% endblock %}
	<footer>
		Powered by <a href="https://github.com/felixfoertsch/blatt">Blatt</a>
	</footer>
</body>
</html>

templates/default.html:

{% extends "base.html" %}
{% block body %}
<article>
	{% if title %}<h1>{{ title }}</h1>{% endif %}
	{{ content | safe }}
</article>
{% endblock %}

templates/post.html:

{% extends "base.html" %}
{% block body %}
<article>
	<header>
		<h1>{{ title }}</h1>
		{% if published_date or reading_time %}
		<p style="color: #666; font-size: 0.875rem;">
			{% if published_date %}<time>{{ published_date }}</time>{% endif %}
			{% if updated_date %} · Updated: <time>{{ updated_date }}</time>{% endif %}
			{% if reading_time %} · {{ reading_time }}{% endif %}
		</p>
		{% endif %}
		{% if taxonomy and taxonomy.tags %}
		<p style="font-size: 0.875rem;">
			{% for tag in taxonomy.tags %}<a href="/tags/{{ tag }}">{{ tag }}</a>{% if not loop.last %}, {% endif %}{% endfor %}
		</p>
		{% endif %}
	</header>
	{{ content | safe }}
</article>
{% endblock %}

templates/collection.html:

{% extends "base.html" %}
{% block body %}
<h1>{{ title }}</h1>
{% if content %}<div>{{ content | safe }}</div>{% endif %}
{% if pages %}
<ul style="list-style: none; padding: 0;">
	{% for page in pages %}
	<li style="margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #eee;">
		<a href="{{ page.slug }}"><strong>{{ page.title }}</strong></a>
		{% if page.published_date %}<br><time style="color: #666; font-size: 0.875rem;">{{ page.published_date }}</time>{% endif %}
		{% if page.description %}<br><span style="color: #555; font-size: 0.875rem;">{{ page.description }}</span>{% endif %}
		{% if page.draft %}<span style="color: #faad14; font-size: 0.75rem;"> [draft]</span>{% endif %}
	</li>
	{% endfor %}
</ul>
{% endif %}
{% endblock %}

templates/404.html:

{% extends "base.html" %}
{% block title %}Not Found{% endblock %}
{% block body %}
<h1>404 — Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<p><a href="/">← Back to home</a></p>
{% endblock %}
  • Step 4: Implement template engine

src/templates/engine.ts:

import nunjucks from "nunjucks";
import fs from "node:fs";
import path from "node:path";

export interface TemplateEngine {
	render(templateName: string, context: Record<string, unknown>): string;
}

export function createTemplateEngine(
	userTemplatesDir?: string,
	builtinTemplatesDir?: string,
): TemplateEngine {
	const builtinDir = builtinTemplatesDir ?? path.resolve(import.meta.dirname, "../../templates");

	// Nunjucks searches directories in order — user templates override built-in ones
	const searchPaths: string[] = [];
	if (userTemplatesDir && fs.existsSync(userTemplatesDir)) {
		searchPaths.push(userTemplatesDir);
	}
	searchPaths.push(builtinDir);

	const env = nunjucks.configure(searchPaths, {
		autoescape: false,
		noCache: true, // always re-read templates (file watching)
	});

	return {
		render(templateName: string, context: Record<string, unknown>): string {
			const fileName = `${templateName}.html`;

			// check if template exists in any search path
			const exists = searchPaths.some((dir) =>
				fs.existsSync(path.join(dir, fileName)),
			);

			const resolvedTemplate = exists ? fileName : "default.html";
			return env.render(resolvedTemplate, context);
		},
	};
}
  • Step 5: Implement template context builder

src/templates/context.ts:

import type { Page, CollectionPage } from "../content/types.js";
import type { BlattConfig } from "../config/schema.js";
import type { TocEntry } from "../markdown/pipeline.js";

export function buildTemplateContext(
	page: Page,
	config: BlattConfig,
	options: {
		toc?: TocEntry[];
		metaTags?: string;
		isDraft?: boolean;
		collectionPages?: Array<Record<string, unknown>>;
	} = {},
): Record<string, unknown> {
	const wordCount = page.markdown.split(/\s+/).filter(Boolean).length;
	const readingMinutes = Math.max(1, Math.ceil(wordCount / 200));

	return {
		title: page.meta.title,
		description: page.meta.description,
		content: page.html,
		published_date: page.meta.published.publishedDate,
		updated_date: page.meta.published.updatedDate,
		reading_time: `${readingMinutes} min read`,
		taxonomy: page.meta.taxonomy,
		toc: options.toc ?? [],
		meta_tags: options.metaTags ?? "",
		config,
		page: {
			slug: page.slug,
			meta: page.meta,
		},
		draft_banner: options.isDraft ?? false,
		pages: options.collectionPages ?? [],
		// spread raw frontmatter so custom fields are available
		...page.meta.raw,
	};
}
  • Step 6: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/templates/engine.test.ts

Expected: all tests PASS.

  • Step 7: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add nunjucks template engine, default theme, context builder"

Task 7: Preview Middleware (Draft Access Control)

Files:

  • Create: src/preview/middleware.ts

  • Create: src/preview/middleware.test.ts

  • Step 1: Write failing tests

src/preview/middleware.test.ts:

import { describe, expect, it } from "vitest";
import { canViewDrafts } from "./middleware.js";

describe("canViewDrafts", () => {
	it("allows access from trusted network", () => {
		const result = canViewDrafts({
			remoteIp: "192.168.23.50",
			trustedNetworks: ["192.168.23.0/24"],
			previewToken: "",
			queryToken: undefined,
		});
		expect(result).toBe(true);
	});

	it("denies access from untrusted network without token", () => {
		const result = canViewDrafts({
			remoteIp: "203.0.113.50",
			trustedNetworks: ["192.168.23.0/24"],
			previewToken: "secret",
			queryToken: undefined,
		});
		expect(result).toBe(false);
	});

	it("allows access with correct preview token", () => {
		const result = canViewDrafts({
			remoteIp: "203.0.113.50",
			trustedNetworks: [],
			previewToken: "secret",
			queryToken: "secret",
		});
		expect(result).toBe(true);
	});

	it("denies access with wrong preview token", () => {
		const result = canViewDrafts({
			remoteIp: "203.0.113.50",
			trustedNetworks: [],
			previewToken: "secret",
			queryToken: "wrong",
		});
		expect(result).toBe(false);
	});

	it("handles multiple trusted networks", () => {
		const result = canViewDrafts({
			remoteIp: "10.0.0.5",
			trustedNetworks: ["192.168.23.0/24", "10.0.0.0/8"],
			previewToken: "",
			queryToken: undefined,
		});
		expect(result).toBe(true);
	});

	it("denies when no trusted networks and no token configured", () => {
		const result = canViewDrafts({
			remoteIp: "192.168.23.50",
			trustedNetworks: [],
			previewToken: "",
			queryToken: undefined,
		});
		expect(result).toBe(false);
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/preview/middleware.test.ts

Expected: FAIL.

  • Step 3: Implement draft access control

src/preview/middleware.ts:

interface DraftAccessParams {
	remoteIp: string;
	trustedNetworks: string[];
	previewToken: string;
	queryToken: string | undefined;
}

function ipToNumber(ip: string): number {
	const parts = ip.split(".").map(Number);
	return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
}

function isIpInCidr(ip: string, cidr: string): boolean {
	const [network, prefixStr] = cidr.split("/");
	const prefix = Number.parseInt(prefixStr, 10);
	const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
	return (ipToNumber(ip) & mask) === (ipToNumber(network) & mask);
}

export function canViewDrafts(params: DraftAccessParams): boolean {
	// check trusted networks
	for (const cidr of params.trustedNetworks) {
		if (isIpInCidr(params.remoteIp, cidr)) {
			return true;
		}
	}

	// check preview token
	if (params.previewToken && params.queryToken === params.previewToken) {
		return true;
	}

	return false;
}
  • Step 4: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/preview/middleware.test.ts

Expected: all tests PASS.

  • Step 5: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add draft access control, cidr network matching, token preview"

Task 8: SEO Meta Generation

Files:

  • Create: src/seo/meta.ts

  • Create: src/seo/meta.test.ts

  • Step 1: Write failing tests

src/seo/meta.test.ts:

import { describe, expect, it } from "vitest";
import { generateMetaTags, calculateReadingTime } from "./meta.js";

describe("calculateReadingTime", () => {
	it("calculates reading time from word count", () => {
		const words = Array(400).fill("word").join(" ");
		expect(calculateReadingTime(words)).toBe("2 min read");
	});

	it("returns minimum 1 min for short content", () => {
		expect(calculateReadingTime("short")).toBe("1 min read");
	});
});

describe("generateMetaTags", () => {
	it("generates Open Graph tags", () => {
		const html = generateMetaTags({
			title: "My Post",
			description: "A great post",
			url: "https://example.com/blog/my-post",
			author: "Felix",
			publishedDate: "2026-04-04",
			siteName: "Test Site",
		});
		expect(html).toContain('og:title" content="My Post"');
		expect(html).toContain('og:description" content="A great post"');
		expect(html).toContain('og:url" content="https://example.com/blog/my-post"');
	});

	it("generates canonical URL", () => {
		const html = generateMetaTags({
			title: "Test",
			url: "https://example.com/about",
			siteName: "Test",
		});
		expect(html).toContain('rel="canonical" href="https://example.com/about"');
	});

	it("generates JSON-LD for posts with dates", () => {
		const html = generateMetaTags({
			title: "My Post",
			description: "A post",
			url: "https://example.com/post",
			author: "Felix",
			publishedDate: "2026-04-04",
			siteName: "Test",
		});
		expect(html).toContain("application/ld+json");
		expect(html).toContain('"@type":"Article"');
		expect(html).toContain('"datePublished":"2026-04-04"');
	});

	it("omits JSON-LD when no published date", () => {
		const html = generateMetaTags({
			title: "About",
			url: "https://example.com/about",
			siteName: "Test",
		});
		expect(html).not.toContain("ld+json");
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/seo/meta.test.ts

Expected: FAIL.

  • Step 3: Implement SEO meta generation

src/seo/meta.ts:

export function calculateReadingTime(text: string): string {
	const wordCount = text.split(/\s+/).filter(Boolean).length;
	const minutes = Math.max(1, Math.ceil(wordCount / 200));
	return `${minutes} min read`;
}

interface MetaTagParams {
	title: string;
	description?: string;
	url: string;
	author?: string;
	publishedDate?: string | null;
	updatedDate?: string | null;
	image?: string;
	siteName: string;
}

export function generateMetaTags(params: MetaTagParams): string {
	const tags: string[] = [];

	// canonical
	tags.push(`<link rel="canonical" href="${params.url}">`);

	// Open Graph
	tags.push(`<meta property="og:title" content="${escapeAttr(params.title)}">`);
	tags.push(`<meta property="og:url" content="${params.url}">`);
	tags.push(`<meta property="og:site_name" content="${escapeAttr(params.siteName)}">`);

	if (params.description) {
		tags.push(
			`<meta property="og:description" content="${escapeAttr(params.description)}">`,
		);
	}

	if (params.publishedDate) {
		tags.push(`<meta property="og:type" content="article">`);
		tags.push(
			`<meta property="article:published_time" content="${params.publishedDate}">`,
		);
	} else {
		tags.push(`<meta property="og:type" content="website">`);
	}

	if (params.image) {
		tags.push(`<meta property="og:image" content="${params.image}">`);
	}

	// JSON-LD (only for dated articles)
	if (params.publishedDate) {
		const jsonLd: Record<string, unknown> = {
			"@context": "https://schema.org",
			"@type": "Article",
			headline: params.title,
			datePublished: params.publishedDate,
			url: params.url,
		};
		if (params.updatedDate) {
			jsonLd.dateModified = params.updatedDate;
		}
		if (params.author) {
			jsonLd.author = { "@type": "Person", name: params.author };
		}
		if (params.description) {
			jsonLd.description = params.description;
		}

		tags.push(
			`<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>`,
		);
	}

	return tags.join("\n");
}

function escapeAttr(str: string): string {
	return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
  • Step 4: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/seo/meta.test.ts

Expected: all tests PASS.

  • Step 5: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add seo meta generation, open graph, json-ld, reading time"

Task 9: Content Cache with File Watching

Files:

  • Create: src/content/cache.ts

  • Step 1: Implement content cache

src/content/cache.ts:

import fs from "node:fs";
import path from "node:path";
import { readContentTree } from "./reader.js";
import { createMarkdownPipeline, type RenderResult, type TocEntry } from "../markdown/pipeline.js";
import type { Page } from "./types.js";
import type { BlattConfig } from "../config/schema.js";

export interface ContentCache {
	getPage(slug: string): Page | undefined;
	getAllPages(): Page[];
	getPublishedPages(): Page[];
	getToc(slug: string): TocEntry[];
	stop(): void;
}

export async function createContentCache(
	contentDir: string,
	config: BlattConfig,
): Promise<ContentCache> {
	const pipeline = createMarkdownPipeline(config.markdown);
	let pages: Page[] = [];
	let tocMap = new Map<string, TocEntry[]>();

	async function rebuild() {
		const rawPages = readContentTree(contentDir);
		const rendered: Page[] = [];
		const newTocMap = new Map<string, TocEntry[]>();

		for (const page of rawPages) {
			const result = await pipeline.render(page.markdown);
			rendered.push({ ...page, html: result.html });
			newTocMap.set(page.slug, result.toc);
		}

		pages = rendered;
		tocMap = newTocMap;
		console.log(`[blatt] loaded ${pages.length} pages from ${contentDir}`);
	}

	// initial build
	await rebuild();

	// watch for changes
	let debounceTimer: ReturnType<typeof setTimeout> | null = null;
	const watcher = fs.watch(contentDir, { recursive: true }, () => {
		if (debounceTimer) clearTimeout(debounceTimer);
		debounceTimer = setTimeout(() => {
			rebuild().catch((err) => console.error("[blatt] rebuild error:", err));
		}, 200);
	});

	return {
		getPage(slug: string) {
			return pages.find((p) => p.slug === slug);
		},
		getAllPages() {
			return pages;
		},
		getPublishedPages() {
			return pages.filter((p) => !p.meta.published.draft);
		},
		getToc(slug: string) {
			return tocMap.get(slug) ?? [];
		},
		stop() {
			watcher.close();
			if (debounceTimer) clearTimeout(debounceTimer);
		},
	};
}
  • Step 2: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add content cache with file watching, debounced rebuild"

Task 10: Collection Page Resolution

Files:

  • Create: src/content/collection.ts

  • Create: src/content/collection.test.ts

  • Step 1: Write failing tests

src/content/collection.test.ts:

import { describe, expect, it } from "vitest";
import { resolveCollection } from "./collection.js";
import type { Page } from "./types.js";

function makePage(slug: string, publishedDate: string | null, draft = false): Page {
	return {
		slug,
		dirPath: "",
		filePath: "",
		meta: {
			title: slug.split("/").pop() ?? "",
			description: "",
			template: "post",
			published: { draft, publishedDate, updatedDate: null },
			taxonomy: { tags: [], category: [] },
			raw: {},
		},
		markdown: "",
		html: "",
	};
}

describe("resolveCollection", () => {
	const pages = [
		makePage("/blog", null),
		makePage("/blog/2026-04-01-first", "2026-04-01"),
		makePage("/blog/2026-04-03-third", "2026-04-03"),
		makePage("/blog/2026-04-02-second", "2026-04-02"),
		makePage("/blog/draft-post", null, true),
		makePage("/about", null),
	];

	it("collects children from a path", () => {
		const result = resolveCollection(pages, {
			from: "blog",
			sort: "published",
			order: "desc",
		});
		// should include the 3 blog posts (not the blog index, not /about)
		expect(result).toHaveLength(4); // 3 published + 1 draft
	});

	it("sorts by published date descending", () => {
		const result = resolveCollection(pages, {
			from: "blog",
			sort: "published",
			order: "desc",
		});
		const published = result.filter((p) => !p.meta.published.draft);
		expect(published[0].slug).toBe("/blog/2026-04-03-third");
		expect(published[1].slug).toBe("/blog/2026-04-02-second");
		expect(published[2].slug).toBe("/blog/2026-04-01-first");
	});

	it("sorts ascending when specified", () => {
		const result = resolveCollection(pages, {
			from: "blog",
			sort: "published",
			order: "asc",
		});
		const published = result.filter((p) => !p.meta.published.draft);
		expect(published[0].slug).toBe("/blog/2026-04-01-first");
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/content/collection.test.ts

Expected: FAIL.

  • Step 3: Implement collection resolution

src/content/collection.ts:

import type { Page, CollectConfig } from "./types.js";

export function resolveCollection(
	allPages: Page[],
	collect: CollectConfig,
): Page[] {
	const basePath = `/${collect.from}`;

	// collect direct children of the base path (not the base itself)
	const children = allPages.filter((p) => {
		if (p.slug === basePath || p.slug === "/") return false;
		// must be under basePath and be a direct child (one level deep)
		if (!p.slug.startsWith(`${basePath}/`)) return false;
		const rest = p.slug.slice(basePath.length + 1);
		return !rest.includes("/"); // direct child only
	});

	// sort
	const sorted = [...children].sort((a, b) => {
		if (collect.sort === "published") {
			const dateA = a.meta.published.publishedDate ?? "";
			const dateB = b.meta.published.publishedDate ?? "";
			return collect.order === "desc"
				? dateB.localeCompare(dateA)
				: dateA.localeCompare(dateB);
		}
		// fallback: sort by slug
		return collect.order === "desc"
			? b.slug.localeCompare(a.slug)
			: a.slug.localeCompare(b.slug);
	});

	return sorted;
}
  • Step 4: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/content/collection.test.ts

Expected: all tests PASS.

  • Step 5: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add collection page resolution, sort by published date"

Task 11: Page Serving Routes

Files:

  • Create: src/routes/pages.ts

  • Create: src/routes/assets.ts

  • Create: src/routes/pages.test.ts

  • Modify: src/app.ts

  • Step 1: Write failing integration tests

src/routes/pages.test.ts:

import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { createBlattApp } from "../app.js";
import path from "node:path";

const fixturesDir = path.resolve(import.meta.dirname, "../../test/fixtures");

describe("page routes", () => {
	let app: Awaited<ReturnType<typeof createBlattApp>>;

	beforeAll(async () => {
		app = await createBlattApp({
			contentDir: path.join(fixturesDir, "basic"),
			config: {
				site: { name: "Test", url: "http://localhost", language: "en", description: "", author: "" },
				server: { port: 3000, trusted_networks: [], preview_token: "" },
				markdown: {
					syntax_highlighting: true, footnotes: true, smart_typography: true,
					heading_anchors: true, figure_captions: true, external_links: true,
					toc: true, math: true, admonitions: true, definition_lists: true,
					task_lists: true, mermaid: true, abbreviations: true,
					superscript_subscript: true, highlight_mark: true,
				},
				feed: { enabled: false, path: "/feed.xml" },
				sitemap: { enabled: false, path: "/sitemap.xml" },
			},
		});
	});

	afterAll(() => {
		app.cache.stop();
	});

	it("serves a page at its slug", async () => {
		const res = await app.hono.request("/hello");
		expect(res.status).toBe(200);
		const html = await res.text();
		expect(html).toContain("Hello World");
		expect(html).toContain("<!DOCTYPE html>");
	});

	it("returns 404 for nonexistent pages", async () => {
		const res = await app.hono.request("/nonexistent");
		expect(res.status).toBe(404);
		const html = await res.text();
		expect(html).toContain("Not Found");
	});
});

describe("draft access", () => {
	let app: Awaited<ReturnType<typeof createBlattApp>>;

	beforeAll(async () => {
		app = await createBlattApp({
			contentDir: path.join(fixturesDir, "drafts"),
			config: {
				site: { name: "Test", url: "http://localhost", language: "en", description: "", author: "" },
				server: { port: 3000, trusted_networks: ["127.0.0.0/8"], preview_token: "secret" },
				markdown: {
					syntax_highlighting: true, footnotes: true, smart_typography: true,
					heading_anchors: true, figure_captions: true, external_links: true,
					toc: true, math: true, admonitions: true, definition_lists: true,
					task_lists: true, mermaid: true, abbreviations: true,
					superscript_subscript: true, highlight_mark: true,
				},
				feed: { enabled: false, path: "/feed.xml" },
				sitemap: { enabled: false, path: "/sitemap.xml" },
			},
		});
	});

	afterAll(() => {
		app.cache.stop();
	});

	it("returns 404 for drafts without preview access", async () => {
		const res = await app.hono.request("/wip", {
			headers: { "x-forwarded-for": "203.0.113.1" },
		});
		expect(res.status).toBe(404);
	});

	it("shows drafts with correct preview token", async () => {
		const res = await app.hono.request("/wip?preview=secret", {
			headers: { "x-forwarded-for": "203.0.113.1" },
		});
		expect(res.status).toBe(200);
		const html = await res.text();
		expect(html).toContain("Work In Progress");
		expect(html).toContain("Draft");
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/routes/pages.test.ts

Expected: FAIL.

  • Step 3: Implement asset serving

src/routes/assets.ts:

import { Hono } from "hono";
import fs from "node:fs";
import path from "node:path";
import { getMimeType } from "hono/utils/mime";

export function createAssetRoutes(contentDir: string) {
	const app = new Hono();

	// serve static files co-located with content
	app.get("*", async (c, next) => {
		const urlPath = c.req.path;

		// skip if this looks like a page request (no extension)
		if (!path.extname(urlPath)) {
			return next();
		}

		const filePath = path.join(contentDir, urlPath);

		if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
			return next();
		}

		const ext = path.extname(filePath);
		const mimeType = getMimeType(ext) ?? "application/octet-stream";
		const content = fs.readFileSync(filePath);
		return c.body(content, 200, { "Content-Type": mimeType });
	});

	return app;
}
  • Step 4: Implement page serving routes

src/routes/pages.ts:

import { Hono } from "hono";
import type { ContentCache } from "../content/cache.js";
import type { BlattConfig } from "../config/schema.js";
import { createTemplateEngine } from "../templates/engine.js";
import { buildTemplateContext } from "../templates/context.js";
import { generateMetaTags } from "../seo/meta.js";
import { canViewDrafts } from "../preview/middleware.js";
import { resolveCollection } from "../content/collection.js";
import type { CollectConfig } from "../content/types.js";

export function createPageRoutes(
	cache: ContentCache,
	config: BlattConfig,
	userTemplatesDir?: string,
) {
	const app = new Hono();
	const engine = createTemplateEngine(userTemplatesDir);

	app.get("*", async (c) => {
		let slug = c.req.path;
		// normalize: strip trailing slash, ensure leading slash
		if (slug !== "/" && slug.endsWith("/")) {
			slug = slug.slice(0, -1);
		}

		const page = cache.getPage(slug);

		if (!page) {
			// try 404 page
			const html = engine.render("404", { config, title: "Not Found" });
			return c.html(html, 404);
		}

		// draft access control
		if (page.meta.published.draft) {
			const remoteIp =
				(c.req.header("x-forwarded-for") ?? "127.0.0.1").split(",")[0].trim();
			const queryToken = c.req.query("preview");
			const canView = canViewDrafts({
				remoteIp,
				trustedNetworks: config.server.trusted_networks,
				previewToken: config.server.preview_token,
				queryToken,
			});

			if (!canView) {
				const html = engine.render("404", { config, title: "Not Found" });
				return c.html(html, 404);
			}
		}

		// check if this is a collection page
		const collectConfig = page.meta.raw.collect as CollectConfig | undefined;
		let collectionPages: Array<Record<string, unknown>> = [];

		if (collectConfig) {
			const allPages = cache.getAllPages();
			const remoteIp =
				(c.req.header("x-forwarded-for") ?? "127.0.0.1").split(",")[0].trim();
			const queryToken = c.req.query("preview");
			const showDrafts = canViewDrafts({
				remoteIp,
				trustedNetworks: config.server.trusted_networks,
				previewToken: config.server.preview_token,
				queryToken,
			});

			let children = resolveCollection(allPages, {
				from: collectConfig.from ?? slug.slice(1),
				sort: collectConfig.sort ?? "published",
				order: collectConfig.order ?? "desc",
			});

			if (!showDrafts) {
				children = children.filter((p) => !p.meta.published.draft);
			}

			collectionPages = children.map((p) => ({
				title: p.meta.title,
				description: p.meta.description,
				slug: p.slug,
				published_date: p.meta.published.publishedDate,
				updated_date: p.meta.published.updatedDate,
				draft: p.meta.published.draft,
				taxonomy: p.meta.taxonomy,
			}));
		}

		const metaTags = generateMetaTags({
			title: page.meta.title,
			description: page.meta.description,
			url: `${config.site.url}${page.slug}`,
			author: config.site.author,
			publishedDate: page.meta.published.publishedDate,
			updatedDate: page.meta.published.updatedDate,
			siteName: config.site.name,
		});

		const toc = cache.getToc(slug);

		const context = buildTemplateContext(page, config, {
			toc,
			metaTags,
			isDraft: page.meta.published.draft,
			collectionPages,
		});

		const html = engine.render(page.meta.template, context);
		return c.html(html);
	});

	return app;
}
  • Step 5: Rewrite src/app.ts to wire everything together

src/app.ts:

import { Hono } from "hono";
import type { BlattConfig } from "./config/schema.js";
import { createContentCache, type ContentCache } from "./content/cache.js";
import { createAssetRoutes } from "./routes/assets.js";
import { createPageRoutes } from "./routes/pages.js";

interface BlattAppOptions {
	contentDir: string;
	templatesDir?: string;
	config: BlattConfig;
}

interface BlattApp {
	hono: Hono;
	cache: ContentCache;
}

export async function createBlattApp(options: BlattAppOptions): Promise<BlattApp> {
	const { contentDir, templatesDir, config } = options;

	const cache = await createContentCache(contentDir, config);

	const app = new Hono();

	// static assets first (files with extensions)
	app.route("/", createAssetRoutes(contentDir));

	// page routes (catch-all)
	app.route("/", createPageRoutes(cache, config, templatesDir));

	return { hono: app, cache };
}
  • Step 6: Update src/index.ts to use the new app factory

src/index.ts:

import fs from "node:fs";
import path from "node:path";
import { createBlattApp } from "./app.js";
import { loadConfigFile } from "./config/loader.js";

const dataDir = process.env.BLATT_DATA_DIR ?? "/data";
const contentDir = path.join(dataDir, "content");
const templatesDir = path.join(dataDir, "templates");
const configPath = path.join(dataDir, "config.toml");

// ensure content directory exists
if (!fs.existsSync(contentDir)) {
	fs.mkdirSync(contentDir, { recursive: true });
}

const config = loadConfigFile(configPath);
const port = Number(process.env.PORT) || config.server.port;

const { hono } = await createBlattApp({ contentDir, templatesDir, config });

console.log(`[blatt] listening on http://localhost:${port}`);

export default {
	port,
	fetch: hono.fetch,
};
  • Step 7: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/routes/pages.test.ts

Expected: all tests PASS.

  • Step 8: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add page serving routes, asset serving, draft access, wire app together"

Task 12: RSS Feed and Sitemap

Files:

  • Create: src/routes/feed.ts

  • Create: src/routes/feed.test.ts

  • Create: src/routes/sitemap.ts

  • Step 1: Write failing tests for feed

src/routes/feed.test.ts:

import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { generateFeedXml } from "./feed.js";
import { generateSitemapXml } from "./sitemap.js";
import type { Page } from "../content/types.js";

function makePage(slug: string, title: string, publishedDate: string): Page {
	return {
		slug,
		dirPath: "",
		filePath: "",
		meta: {
			title,
			description: `About ${title}`,
			template: "post",
			published: { draft: false, publishedDate, updatedDate: null },
			taxonomy: { tags: [], category: [] },
			raw: {},
		},
		markdown: "content",
		html: "<p>content</p>",
	};
}

describe("RSS feed", () => {
	const pages = [
		makePage("/blog/post-a", "Post A", "2026-04-01"),
		makePage("/blog/post-b", "Post B", "2026-04-02"),
	];

	it("generates valid RSS XML", () => {
		const xml = generateFeedXml(pages, {
			title: "Test Blog",
			description: "A test blog",
			siteUrl: "https://example.com",
			author: "Felix",
		});
		expect(xml).toContain("<?xml");
		expect(xml).toContain("<rss");
		expect(xml).toContain("<title>Test Blog</title>");
		expect(xml).toContain("Post A");
		expect(xml).toContain("Post B");
		expect(xml).toContain("https://example.com/blog/post-a");
	});
});

describe("sitemap", () => {
	const pages = [
		makePage("/", "Home", "2026-04-01"),
		makePage("/about", "About", "2026-03-01"),
		makePage("/blog/post", "Post", "2026-04-02"),
	];

	it("generates valid sitemap XML", () => {
		const xml = generateSitemapXml(pages, "https://example.com");
		expect(xml).toContain("<?xml");
		expect(xml).toContain("<urlset");
		expect(xml).toContain("https://example.com/");
		expect(xml).toContain("https://example.com/about");
		expect(xml).toContain("https://example.com/blog/post");
	});
});
  • Step 2: Run tests to verify they fail
cd ~/Developer/blatt
bun test src/routes/feed.test.ts

Expected: FAIL.

  • Step 3: Implement feed generation

src/routes/feed.ts:

import { Hono } from "hono";
import type { ContentCache } from "../content/cache.js";
import type { BlattConfig } from "../config/schema.js";
import type { Page } from "../content/types.js";

interface FeedOptions {
	title: string;
	description: string;
	siteUrl: string;
	author: string;
}

export function generateFeedXml(pages: Page[], options: FeedOptions): string {
	const items = pages
		.filter((p) => p.meta.published.publishedDate)
		.sort((a, b) =>
			(b.meta.published.publishedDate ?? "").localeCompare(
				a.meta.published.publishedDate ?? "",
			),
		)
		.slice(0, 20)
		.map(
			(p) => `    <item>
      <title>${escapeXml(p.meta.title)}</title>
      <link>${options.siteUrl}${p.slug}</link>
      <guid>${options.siteUrl}${p.slug}</guid>
      <pubDate>${new Date(p.meta.published.publishedDate!).toUTCString()}</pubDate>
      <description>${escapeXml(p.meta.description || p.meta.title)}</description>
    </item>`,
		)
		.join("\n");

	return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>${escapeXml(options.title)}</title>
    <link>${options.siteUrl}</link>
    <description>${escapeXml(options.description)}</description>
    <atom:link href="${options.siteUrl}/feed.xml" rel="self" type="application/rss+xml"/>
${items}
  </channel>
</rss>`;
}

export function createFeedRoute(cache: ContentCache, config: BlattConfig) {
	const app = new Hono();

	app.get(config.feed.path, (c) => {
		const pages = cache.getPublishedPages();
		const xml = generateFeedXml(pages, {
			title: config.site.name,
			description: config.site.description,
			siteUrl: config.site.url,
			author: config.site.author,
		});
		return c.body(xml, 200, { "Content-Type": "application/rss+xml; charset=utf-8" });
	});

	return app;
}

function escapeXml(str: string): string {
	return str
		.replace(/&/g, "&amp;")
		.replace(/</g, "&lt;")
		.replace(/>/g, "&gt;")
		.replace(/"/g, "&quot;")
		.replace(/'/g, "&apos;");
}
  • Step 4: Implement sitemap generation

src/routes/sitemap.ts:

import { Hono } from "hono";
import type { ContentCache } from "../content/cache.js";
import type { BlattConfig } from "../config/schema.js";
import type { Page } from "../content/types.js";

export function generateSitemapXml(pages: Page[], siteUrl: string): string {
	const urls = pages
		.map(
			(p) => `  <url>
    <loc>${siteUrl}${p.slug === "/" ? "/" : p.slug}</loc>
    ${p.meta.published.updatedDate ? `<lastmod>${p.meta.published.updatedDate}</lastmod>` : p.meta.published.publishedDate ? `<lastmod>${p.meta.published.publishedDate}</lastmod>` : ""}
  </url>`,
		)
		.join("\n");

	return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
}

export function createSitemapRoute(cache: ContentCache, config: BlattConfig) {
	const app = new Hono();

	app.get(config.sitemap.path, (c) => {
		const pages = cache.getPublishedPages();
		const xml = generateSitemapXml(pages, config.site.url);
		return c.body(xml, 200, { "Content-Type": "application/xml; charset=utf-8" });
	});

	return app;
}
  • Step 5: Wire feed and sitemap into app.ts

Add to src/app.ts, before the page routes:

import { createFeedRoute } from "./routes/feed.js";
import { createSitemapRoute } from "./routes/sitemap.js";

// inside createBlattApp, before page routes:
if (config.feed.enabled) {
	app.route("/", createFeedRoute(cache, config));
}
if (config.sitemap.enabled) {
	app.route("/", createSitemapRoute(cache, config));
}
  • Step 6: Run tests to verify they pass
cd ~/Developer/blatt
bun test src/routes/feed.test.ts

Expected: all tests PASS.

  • Step 7: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add rss feed, sitemap generation"

Task 13: Taxonomy Routes (Tag/Category Index Pages)

Files:

  • Create: src/routes/taxonomy.ts

  • Step 1: Implement taxonomy routes

src/routes/taxonomy.ts:

import { Hono } from "hono";
import type { ContentCache } from "../content/cache.js";
import type { BlattConfig } from "../config/schema.js";
import { createTemplateEngine } from "../templates/engine.js";

export function createTaxonomyRoutes(
	cache: ContentCache,
	config: BlattConfig,
	userTemplatesDir?: string,
) {
	const app = new Hono();
	const engine = createTemplateEngine(userTemplatesDir);

	// /tags/:tag
	app.get("/tags/:tag", (c) => {
		const tag = c.req.param("tag");
		const pages = cache.getPublishedPages().filter((p) =>
			p.meta.taxonomy.tags.includes(tag),
		);

		const collectionPages = pages.map((p) => ({
			title: p.meta.title,
			description: p.meta.description,
			slug: p.slug,
			published_date: p.meta.published.publishedDate,
			taxonomy: p.meta.taxonomy,
		}));

		const html = engine.render("collection", {
			title: `Tag: ${tag}`,
			content: "",
			pages: collectionPages,
			config,
		});

		return c.html(html);
	});

	// /category/:category
	app.get("/category/:category", (c) => {
		const category = c.req.param("category");
		const pages = cache.getPublishedPages().filter((p) =>
			p.meta.taxonomy.category.includes(category),
		);

		const collectionPages = pages.map((p) => ({
			title: p.meta.title,
			description: p.meta.description,
			slug: p.slug,
			published_date: p.meta.published.publishedDate,
			taxonomy: p.meta.taxonomy,
		}));

		const html = engine.render("collection", {
			title: `Category: ${category}`,
			content: "",
			pages: collectionPages,
			config,
		});

		return c.html(html);
	});

	return app;
}
  • Step 2: Wire into app.ts

Add to src/app.ts, before page routes:

import { createTaxonomyRoutes } from "./routes/taxonomy.js";

// inside createBlattApp, before page routes:
app.route("/", createTaxonomyRoutes(cache, config, templatesDir));
  • Step 3: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add taxonomy routes for tag, category index pages"

Task 14: Welcome Page (First-Run Experience)

Files:

  • Create: templates/welcome.html

  • Modify: src/routes/pages.ts

  • Step 1: Create welcome template

templates/welcome.html:

{% extends "base.html" %}
{% block title %}Welcome{% endblock %}
{% block body %}
<article>
	<h1>Welcome to Blatt</h1>
	<p>Your content server is running. Drop markdown files into the content directory to get started.</p>

	<h2>Quick Start</h2>
	<ol>
		<li>Create a folder in your content directory, e.g. <code>content/hello/</code></li>
		<li>Add an <code>index.md</code> file with some markdown</li>
		<li>Visit <code>/hello</code> in your browser</li>
	</ol>

	<h2>Example <code>index.md</code></h2>
	<pre><code>---
title: My First Page
published: 2026-04-04
---

# Hello from Blatt

This is my first page.</code></pre>

	<h2>Learn More</h2>
	<p>See the <a href="https://github.com/felixfoertsch/blatt">Blatt documentation</a> for the full guide.</p>
</article>
{% endblock %}
  • Step 2: Add welcome page logic to page routes

In src/routes/pages.ts, at the beginning of the GET * handler, before the page lookup, add a check for empty content:

// at the top of the GET * handler, after slug normalization:
if (slug === "/" && cache.getAllPages().length === 0) {
	const html = engine.render("welcome", { config, title: "Welcome" });
	return c.html(html);
}
  • Step 3: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add welcome page for first-run experience"

Task 15: Docker Setup

Files:

  • Create: Dockerfile

  • Create: docker-compose.yml

  • Create: .dockerignore

  • Step 1: Create .dockerignore

node_modules
dist
.git
.env
.mise.toml
.mise.local.toml
test
  • Step 2: Create Dockerfile
FROM oven/bun:1-alpine AS base
WORKDIR /app

COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production

COPY src/ src/
COPY templates/ templates/

ENV BLATT_DATA_DIR=/data
EXPOSE 3000

CMD ["bun", "src/index.ts"]
  • Step 3: Create docker-compose.yml
services:
  blatt:
    build: .
    ports:
      - "3000:3000"
    volumes:
      - ./content:/data/content
      - ./templates-custom:/data/templates
      - ./config.toml:/data/config.toml
    restart: unless-stopped
  • Step 4: Test Docker build
cd ~/Developer/blatt
docker build -t blatt .

Expected: build succeeds.

  • Step 5: Test Docker run with fixtures
cd ~/Developer/blatt
docker run --rm -p 3001:3000 \
  -v "$(pwd)/test/fixtures/basic:/data/content" \
  blatt &
sleep 2
curl -s http://localhost:3001/hello
# Expected: HTML containing "Hello World"
docker stop $(docker ps -q --filter ancestor=blatt)
  • Step 6: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add dockerfile, docker-compose for containerized deployment"

Task 16: Integration Smoke Test

Files:

  • Create: test/smoke.test.ts

  • Step 1: Write end-to-end smoke test

test/smoke.test.ts:

import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { createBlattApp } from "../src/app.js";
import { defaultConfig } from "../src/config/loader.js";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";

describe("smoke test — full pipeline", () => {
	const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blatt-smoke-"));
	let app: Awaited<ReturnType<typeof createBlattApp>>;

	beforeAll(async () => {
		// create a mini content tree
		const blogDir = path.join(tmpDir, "blog", "2026-04-04-test-post");
		fs.mkdirSync(blogDir, { recursive: true });

		fs.writeFileSync(
			path.join(tmpDir, "blog", "index.md"),
			`---
title: Blog
template: collection
collect:
  from: blog
  sort: published
  order: desc
---
`,
		);

		fs.writeFileSync(
			path.join(blogDir, "index.md"),
			`---
title: Test Post
published: 2026-04-04
template: post
description: A test post for smoke testing
taxonomy:
  tags: [test]
---

# Test Post

A paragraph with **bold** and *italic*.

## Table of Contents

## Math

Inline $E = mc^2$ and block:

$$\\int_0^1 x^2 dx$$

## Code

\`\`\`js
const x = 1;
\`\`\`

## Footnote

Text with a footnote[^1].

[^1]: This is the footnote.

:::note
This is an admonition.
:::
`,
		);

		fs.writeFileSync(
			path.join(tmpDir, "about", "index.md").replace("/about/", "/about/"),
			"",
		);
		fs.mkdirSync(path.join(tmpDir, "about"), { recursive: true });
		fs.writeFileSync(
			path.join(tmpDir, "about", "index.md"),
			`---
title: About
---

# About

This is the about page.
`,
		);

		const config = {
			...defaultConfig,
			site: { ...defaultConfig.site, name: "Smoke Test", url: "https://test.local" },
		};

		app = await createBlattApp({ contentDir: tmpDir, config });
	});

	afterAll(() => {
		app.cache.stop();
		fs.rmSync(tmpDir, { recursive: true, force: true });
	});

	it("serves the blog collection page", async () => {
		const res = await app.hono.request("/blog");
		expect(res.status).toBe(200);
		const html = await res.text();
		expect(html).toContain("Blog");
		expect(html).toContain("Test Post");
		expect(html).toContain("/blog/2026-04-04-test-post");
	});

	it("serves the blog post with all features", async () => {
		const res = await app.hono.request("/blog/2026-04-04-test-post");
		expect(res.status).toBe(200);
		const html = await res.text();

		// basic content
		expect(html).toContain("Test Post");
		expect(html).toContain("<strong>bold</strong>");
		expect(html).toContain("<em>italic</em>");

		// math (KaTeX)
		expect(html).toContain("katex");

		// code highlighting
		expect(html).toContain("<pre");

		// footnotes
		expect(html).toContain("footnote");

		// admonition
		expect(html).toContain("callout");

		// heading anchors
		expect(html).toContain('id="math"');

		// SEO
		expect(html).toContain("og:title");
		expect(html).toContain("canonical");

		// reading time
		expect(html).toContain("min read");
	});

	it("serves the about page with default template", async () => {
		const res = await app.hono.request("/about");
		expect(res.status).toBe(200);
		const html = await res.text();
		expect(html).toContain("About");
	});

	it("returns 404 for nonexistent pages", async () => {
		const res = await app.hono.request("/nothing");
		expect(res.status).toBe(404);
	});

	it("generates sitemap", async () => {
		const config = {
			...defaultConfig,
			site: { ...defaultConfig.site, url: "https://test.local" },
			sitemap: { enabled: true, path: "/sitemap.xml" },
		};
		const sitemapApp = await createBlattApp({ contentDir: tmpDir, config });
		const res = await sitemapApp.hono.request("/sitemap.xml");
		expect(res.status).toBe(200);
		const xml = await res.text();
		expect(xml).toContain("<urlset");
		expect(xml).toContain("https://test.local/about");
		sitemapApp.cache.stop();
	});

	it("generates RSS feed", async () => {
		const config = {
			...defaultConfig,
			site: { ...defaultConfig.site, name: "Test", url: "https://test.local" },
			feed: { enabled: true, path: "/feed.xml" },
		};
		const feedApp = await createBlattApp({ contentDir: tmpDir, config });
		const res = await feedApp.hono.request("/feed.xml");
		expect(res.status).toBe(200);
		const xml = await res.text();
		expect(xml).toContain("<rss");
		expect(xml).toContain("Test Post");
		feedApp.cache.stop();
	});
});
  • Step 2: Run all tests
cd ~/Developer/blatt
bun test

Expected: all tests PASS across all files.

  • Step 3: Run Biome linter
cd ~/Developer/blatt
bun lint

Fix any issues.

  • Step 4: Commit
cd ~/Developer/blatt
git add -A
git commit -m "add integration smoke test covering full pipeline"

Task 17: Deploy to Unraid

Files:

  • No new files — operational task

  • Step 1: Stop the existing Grav container on Unraid

ssh unraid "docker stop grav"
  • Step 2: Copy the project to Unraid (or build locally and push image)
cd ~/Developer/blatt
docker build -t blatt:latest .
docker save blatt:latest | ssh unraid "docker load"
  • Step 3: Create content directory on Unraid
ssh unraid "mkdir -p /mnt/cache/appdata/blatt/content/hello"
cat << 'EOF' | ssh unraid "cat > /mnt/cache/appdata/blatt/content/hello/index.md"
---
title: Hello World
published: 2026-04-04
---

# Hello from Blatt

It works!
EOF
  • Step 4: Create config.toml on Unraid
cat << 'EOF' | ssh unraid "cat > /mnt/cache/appdata/blatt/config.toml"
[site]
name = "felixfoertsch.de"
url = "https://felixfoertsch.de"
language = "en"
author = "Felix Förtsch"

[server]
port = 3000
trusted_networks = ["192.168.23.0/24"]
preview_token = ""
EOF
  • Step 5: Run Blatt container
ssh unraid "docker run -d \
  --name blatt \
  --restart unless-stopped \
  -p 8087:3000 \
  -v /mnt/cache/appdata/blatt/content:/data/content \
  -v /mnt/cache/appdata/blatt/config.toml:/data/config.toml \
  blatt:latest"
  • Step 6: Verify it works
ssh unraid "curl -s http://localhost:8087/hello"

Expected: HTML containing "Hello from Blatt"

  • Step 7: Commit plan as complete
cd ~/Developer/felixfoertsch.de
git add docs/superpowers/plans/2026-04-04-blatt.md
git commit -m "add blatt implementation plan"