diff --git a/src/generate.rs b/src/generate.rs
new file mode 100644
index 0000000..4079b8f
--- /dev/null
+++ b/src/generate.rs
@@ -0,0 +1,103 @@
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::str::FromStr;
+
+use crate::html::render_html;
+use crate::metadata::PageMetadata;
+use crate::template;
+use anyhow::{bail, Context};
+
+pub fn copy_recursively(from: &Path, to: &Path) -> anyhow::Result<()> {
+	Command::new("cp").arg("-r").arg(from).arg(to).output()?;
+
+	Ok(())
+}
+
+#[derive(Debug, Default)]
+pub struct Pages<'a> {
+	pages: Vec<PageMetadata<'a>>,
+	assets: Vec<PathBuf>,
+}
+
+impl<'a> Pages<'a> {
+	// {{{ Collect simple pages
+	pub fn add_page(&mut self, path: &Path) -> anyhow::Result<()> {
+		let content_path = PathBuf::from_str("content")?.join(path);
+		let source = std::fs::read_to_string(&content_path).unwrap();
+
+		// We leak all the file contents, which is fine, as we expect them to
+		// live on for the rest of the duration of the program.
+		//
+		// I'm doing this because a lot of places want to reference this,
+		// which makes memory management a bit nasty (I need to be able to
+		// return the metadata out of this function, but that's not really allowed
+		// if it's referencing a "local variable" like the initial string)
+		let source = Box::leak(Box::new(source));
+
+		let events = jotdown::Parser::new(source);
+		let metadata = PageMetadata::new(content_path, source, events)?;
+
+		self.pages.push(metadata);
+
+		Ok(())
+	}
+	// }}}
+	// {{{ Collect directory of pages
+	pub fn add_dir(&mut self, path: &Path) -> anyhow::Result<()> {
+		let content_path = PathBuf::from_str("content")?.join(path);
+
+		for file in std::fs::read_dir(&content_path)? {
+			let file_path = file?.path();
+			let filename = file_path.file_name().unwrap();
+			let path = path.join(filename);
+			if file_path.is_dir() {
+				self.add_dir(&path)?;
+			} else if file_path.extension().map_or(false, |ext| ext == "dj") {
+				self.add_page(&path)?;
+			} else {
+				self.assets.push(path);
+			}
+		}
+
+		Ok(())
+	}
+	// }}}
+	// {{{ Generate
+	pub fn generate(&self, out_root: PathBuf) -> anyhow::Result<()> {
+		for page in &self.pages {
+			let out_dir = out_root.join(page.route.to_path());
+			std::fs::create_dir_all(&out_dir)
+				.with_context(|| format!("Failed to generate {out_dir:?} directory"))?;
+
+			let mut out = String::new();
+			let mut page_renderer = template!("templates/page.html", &mut out)?;
+
+			page_renderer.feed(&mut out, |label, out| {
+				match label {
+					"content" => {
+						let events = jotdown::Parser::new(page.source);
+						render_html(page, &self.pages, events, out)?;
+					}
+					_ => bail!("Unknown label {label} in page template"),
+				}
+
+				Ok(true)
+			})?;
+
+			std::fs::write(out_dir.join("index.html"), out)
+				.with_context(|| format!("Failed to write {out_dir:?} post"))?;
+		}
+
+		let content_root = PathBuf::from_str("content")?;
+		for path in &self.assets {
+			std::fs::create_dir_all(out_root.join(path).parent().unwrap())
+				.with_context(|| format!("Failed to create parent dir for asset at {path:?}"))?;
+
+			std::fs::copy(content_root.join(path), out_root.join(path))
+				.with_context(|| format!("Failed to copy asset at {path:?}"))?;
+		}
+
+		Ok(())
+	}
+	// }}}
+}
diff --git a/src/html.rs b/src/html.rs
index b2ae3b9..140885d 100644
--- a/src/html.rs
+++ b/src/html.rs
@@ -31,7 +31,7 @@ use crate::template::TemplateRenderer;
 /// Render djot content as HTML.
 pub fn render_html<'s>(
 	metadata: &'s PageMetadata,
-	pages: Option<&'s [PageMetadata]>,
+	pages: &'s [PageMetadata],
 	mut events: impl Iterator<Item = Event<'s>>,
 	out: &mut impl std::fmt::Write,
 ) -> anyhow::Result<()> {
@@ -48,7 +48,7 @@ pub struct Writer<'s> {
 	states: Vec<State<'s>>,
 	footnotes: Footnotes<'s>,
 	metadata: Option<&'s PageMetadata<'s>>,
-	pages: Option<&'s [PageMetadata<'s>]>,
+	pages: &'s [PageMetadata<'s>],
 }
 
 #[derive(Debug, Clone)]
@@ -66,7 +66,7 @@ enum State<'s> {
 }
 
 impl<'s> Writer<'s> {
-	pub fn new(metadata: Option<&'s PageMetadata>, pages: Option<&'s [PageMetadata]>) -> Self {
+	pub fn new(metadata: Option<&'s PageMetadata>, pages: &'s [PageMetadata]) -> Self {
 		Self {
 			list_tightness: Vec::new(),
 			states: Vec::new(),
@@ -278,7 +278,7 @@ impl<'s> Writer<'s> {
 					// {{{ Post list
 					Container::Div { class: "posts" } => {
 						write!(out, r#"<ol class="article-list">"#)?;
-						for post in self.pages.ok_or_else(|| anyhow!("No post list given"))? {
+						for post in self.pages {
 							// Skip drafts
 							if post.config.created_at.is_none() {
 								continue;
diff --git a/src/main.rs b/src/main.rs
index eef1f95..4903629 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,125 +1,13 @@
-use std::fmt::Write;
-use std::fs::{self};
-use std::path::{Path, PathBuf};
-use std::process::Command;
+use std::path::PathBuf;
 use std::str::FromStr;
 
-use anyhow::{anyhow, bail, Context};
-use html::render_html;
-use metadata::PageMetadata;
+use anyhow::Context;
 
+mod generate;
 mod html;
 mod metadata;
 mod template;
 
-fn copy_recursively(from: &Path, to: &Path) -> anyhow::Result<()> {
-	Command::new("cp").arg("-r").arg(from).arg(to).output()?;
-
-	Ok(())
-}
-
-// {{{ Generate single page
-fn generate_page<'s>(
-	path: &Path,
-	pages: Option<&[PageMetadata]>,
-) -> anyhow::Result<PageMetadata<'s>> {
-	let content_path = PathBuf::from_str("content")?.join(path);
-
-	let djot_input = std::fs::read_to_string(&content_path).unwrap();
-
-	// We leak all the file contents, which is fine, as we expect them to
-	// live on for the rest of the duration of the program.
-	//
-	// I'm doing this because a lot of places want to reference this,
-	// which makes memory management a bit nasty (I need to be able to
-	// return the metadata out of this function, but that's not really allowed
-	// if it's referencing a "local variable" like the initial string)
-	let djot_input = Box::leak(Box::new(djot_input));
-	let mut out = String::new();
-
-	let mut page_renderer = template!("templates/page.html", &mut out)?;
-
-	let events = jotdown::Parser::new(djot_input);
-	let metadata = PageMetadata::new(content_path, events)?;
-
-	while let Some(label) = page_renderer.current() {
-		if label == "content" {
-			let events = jotdown::Parser::new(djot_input);
-			render_html(&metadata, pages, events, &mut out)?;
-		} else if label == "navigation" {
-			out.write_str(r#"<a href="/"><code>~</code></a>"#)?;
-			out.write_str(" / ")?;
-			out.write_str(r#"<a href="/echoes"><code>echoes</code></a>"#)?;
-		} else {
-			bail!("Unknown label {label} in page template")
-		}
-
-		page_renderer.next(&mut out)?;
-	}
-
-	page_renderer.finish(&mut out)?;
-
-	let mut out_path = PathBuf::from_str("dist")?.join(path);
-	out_path.set_file_name(format!(
-		"{}.html",
-		path.file_stem()
-			.ok_or_else(|| anyhow!("Empty filestem encountered"))?
-			.to_str()
-			.unwrap()
-	));
-	std::fs::write(out_path, out).with_context(|| "Failed to write `arcaea.html` post")?;
-
-	Ok(metadata)
-}
-// }}}
-// {{{ Generate an entire directory
-fn generate_dir(path: &Path) -> anyhow::Result<()> {
-	let content_path = PathBuf::from_str("content")?.join(path);
-	let out_path = PathBuf::from_str("dist")?.join(path);
-	fs::create_dir_all(&out_path)
-		.with_context(|| format!("Could not generate directory {path:?}"))?;
-	let mut files = fs::read_dir(&content_path)?.collect::<Result<Vec<_>, _>>()?;
-
-	// Iterates over the files, removing the `index.dj` file if it does exist.
-	let has_index = files
-		.iter()
-		.enumerate()
-		.find_map(|(i, f)| {
-			if f.path().file_name().and_then(|f| f.to_str()) == Some("index.dj") {
-				Some(i)
-			} else {
-				None
-			}
-		})
-		.map(|index| files.swap_remove(index))
-		.is_some();
-
-	let mut pages = Vec::new();
-
-	for file in files {
-		let file_path = file.path();
-		let filename = file_path.file_name().unwrap();
-		let path = path.join(filename);
-		if file_path.is_dir() {
-			generate_dir(&path)?;
-		} else if file_path.extension().map_or(false, |ext| ext == "dj") {
-			pages.push(generate_page(&path, None)?);
-		} else {
-			fs::copy(content_path.join(filename), out_path.join(filename))?;
-		}
-	}
-
-	if has_index {
-		pages.sort_by_key(|post| (post.config.created_at, post.last_modified));
-
-		let path = path.join("index.dj");
-		generate_page(&path, Some(&pages))?;
-	}
-
-	Ok(())
-}
-// }}}
-
 fn main() -> anyhow::Result<()> {
 	let public_path = PathBuf::from_str("public")?;
 	let dist_path = PathBuf::from_str("dist")?;
@@ -130,12 +18,16 @@ fn main() -> anyhow::Result<()> {
 
 	std::fs::create_dir(&dist_path).with_context(|| "Cannot create `dist` directory")?;
 
+	let mut page = generate::Pages::default();
+	page.add_dir(&PathBuf::from_str("")?)
+		.with_context(|| "Failed to collect directories")?;
+	page.generate(PathBuf::from_str("dist")?)
+		.with_context(|| "Failed to generate markup")?;
+
 	for p in std::fs::read_dir(public_path)? {
-		copy_recursively(&p?.path(), &dist_path)
+		generate::copy_recursively(&p?.path(), &dist_path)
 			.with_context(|| "Cannot copy `public` -> `dist`")?;
 	}
 
-	generate_dir(&PathBuf::new())?;
-
 	Ok(())
 }
diff --git a/src/metadata.rs b/src/metadata.rs
index b7acb15..576a283 100644
--- a/src/metadata.rs
+++ b/src/metadata.rs
@@ -2,6 +2,7 @@ use std::ffi::OsStr;
 use std::fmt::Write;
 use std::path::{Component, Path, PathBuf};
 use std::process::Command;
+use std::str::FromStr;
 
 use anyhow::{anyhow, bail, Context};
 use chrono::{DateTime, FixedOffset, Utc};
@@ -41,6 +42,7 @@ pub enum PageRoute {
 }
 
 impl PageRoute {
+	// {{{ Convert a path to a route
 	fn from_path(path: &Path) -> anyhow::Result<Self> {
 		let Some(Component::Normal(first)) = path.components().nth(1) else {
 			bail!("Path is too short");
@@ -68,6 +70,17 @@ impl PageRoute {
 
 		Ok(result)
 	}
+	// }}}
+	// {{{ Convert a route to a path
+	#[inline]
+	pub fn to_path(&self) -> PathBuf {
+		match self {
+			Self::Home => PathBuf::from_str(".").unwrap(),
+			Self::Posts => PathBuf::from_str("echoes").unwrap(),
+			Self::Post(id) => PathBuf::from_str(&format!("echoes/{id}")).unwrap(),
+		}
+	}
+	// }}}
 }
 // }}}
 // {{{ Metadata
@@ -88,13 +101,18 @@ pub struct PageMetadata<'s> {
 	pub description: Vec<jotdown::Event<'s>>,
 	#[allow(dead_code)]
 	pub toc: Vec<Heading<'s>>,
+	pub source: &'s str,
 
 	pub word_count: usize,
 	pub last_modified: DateTime<FixedOffset>,
 }
 
 impl<'a> PageMetadata<'a> {
-	pub fn new(path: PathBuf, mut events: impl Iterator<Item = Event<'a>>) -> anyhow::Result<Self> {
+	pub fn new(
+		path: PathBuf,
+		source: &'a str,
+		mut events: impl Iterator<Item = Event<'a>>,
+	) -> anyhow::Result<Self> {
 		let last_modified_output = Command::new("git")
 			.arg("log")
 			.arg("-1")
@@ -128,6 +146,7 @@ impl<'a> PageMetadata<'a> {
 			route: PageRoute::from_path(&path)?,
 			title: title.clone(),
 			last_modified,
+			source,
 			config: w.config,
 			description: w.description,
 			toc: w.toc,
diff --git a/src/templates/page.html b/src/templates/page.html
index 3733735..433c545 100644
--- a/src/templates/page.html
+++ b/src/templates/page.html
@@ -28,11 +28,10 @@
   <body>
     <header>
       <nav>
-        {{navigation}}
+        <a href="/"><code>~</code></a> /
+        <a href="/echoes"><code>echoes</code></a>
       </nav>
-    </header
-    <main>
-      {{content}}
-    </main>
+    </header>
+    <main>{{content}}</main>
   </body>
 </html>