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, "&").replace(/"/g, """).replace(/</g, "<");
}
- 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.tsto 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.tsto 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
- 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"