diff --git a/content/arcaea.dj b/content/arcaea.dj
index 82686d0..eabee96 100644
--- a/content/arcaea.dj
+++ b/content/arcaea.dj
@@ -1,3 +1,7 @@
+``` =toml
+created-at: 2024-10-31 15:28
+```
+
 # Why I love arcaea
 
 ## What is arcaea
@@ -32,19 +36,12 @@ Let's write the score formula in a nice, closed form!
 
 Let $`m`, $`p`, $`f` and $`l` denote the amount of MAX PURE, PURE, FAR and LOST notes respectively. The final score can then be computed as
 
-$$`\left\lfloor (2(m + p) + f) \frac{10'000'000}{2(m + p + f)} \right\rfloor`
+$$`m + \left\lfloor (2(m + p) + f) \frac{10'000'000}{2(m + p + f + l)} \right\rfloor`
 :::
 
-{.label="Lagrange's extras: ζ-scoring"}
-:::: in-depth
-::: in-depth-header
-![lagrange](../assets/icons/lagrange.webp){.in-depth-icon}
-
-{.in-depth-heading}
-### EX scoring
-:::
-
-But what if we wanted MAX PURE notes to have a more major contribution to the score? For one, we could start by giving them their own place in the scoring ratio. What would a good ratio look like? A naive approach idea would be to keep the same rate of growth and go with a ratio of 4\:2\:1 for MAX PURE to PURE to FAR. Sadly, issues arise because this can lead to PMs possibly producing terrible scores — it's too big of a departure from the original formula. It turns out the aforementioned [sound voltex](https://en.wikipedia.org/wiki/Sound_Voltex) has already figured out a solution with their optional "EX-scoring" system, which uses a ratio of 5\:4\:2, thus awarding 1.25x the normal points for a MAX PURE.
+{title="Lagrange's extras: ζ-scoring" character="lagrange"}
+::: aside
+UWAAA, but what if we wanted MAX PURE notes to have a more major contribution to the score? For one, we could start by giving them their own place in the scoring ratio. What would a good ratio look like? A naive approach idea would be to keep the same rate of growth and go with a ratio of 4\:2\:1 for MAX PURE to PURE to FAR. Sadly, issues arise because this can lead to PMs possibly producing terrible scores — it's too big of a departure from the original formula. It turns out the aforementioned [sound voltex](https://en.wikipedia.org/wiki/Sound_Voltex) has already figured out a solution with their optional "EX-scoring" system, which uses a ratio of 5\:4\:2, thus awarding 1.25x the normal points for a MAX PURE.
 
 Calling this "EX-scoring" in the context of Arcaea would be confusing (EX being an in-game grade and all), hence I decided to call the Arcaea equivalent of the system "ζ-scoring". In particular, we can compute the ζ-score by only knowing the base score and the number of notes in the chart (call it $`n`) as follows:
 
@@ -53,7 +50,7 @@ Calling this "EX-scoring" in the context of Arcaea would be confusing (EX being
 3. Double the quotient from step 2 and add the remainder to form the final expression of $`5m + 4p + 2f`, which can then be scaled up so $`10,000,000` is the maximum score again.
 
 With a bit of care put into working around the floor function in the actual scoring formula, and performing the computations in a manner that avoids any risks of floating point arithmetic errors, one can implement a very reliable score converter. For instance, the score tracking Arcaea discord bot I'm developing has ζ-scoring well-integrated into everything!
-:::::
+:::
 {% ]]] %}
 
 #### Why arcaea's scoring rocks
diff --git a/public/styles.css b/public/styles.css
index d809e7f..1280f81 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -2,67 +2,75 @@ html {
   font: 100%/1.5 sans-serif;
 }
 
+#page-content {
+  max-width: 70ch;
+  margin-left: auto;
+  margin-right: auto;
+}
+
 blockquote {
   padding-left: 1.25rem;
   border-left: 3px solid;
 }
 
-.in-depth {
-  margin: 1.5rem 0;
-}
-
 ul,
 ol {
   padding-left: 1rem;
 }
 
-.in-depth-header {
+/* {{{ Asides*/
+.aside {
+  margin: 1.5rem 0;
+}
+
+.aside-header {
   display: inline-flex;
   align-items: center;
 }
 
-.in-depth-header > .in-depth-heading {
+.aside-summary > .aside-title {
   text-decoration: underline;
 }
 
-.in-depth-header > * {
+.aside-summary > * {
   padding: 0;
   margin: 0;
 }
 
-img.in-depth-icon {
+img.aside-icon {
   height: 1.75rem;
   margin-right: 0.5rem;
   transform: translateY(-2px);
 }
 
-details {
+.aside {
   background: #ead3ed;
   border-radius: 3px;
   padding: 0.5rem 0.5rem;
   box-sizing: border-box;
 }
 
-summary {
+.aside-summary {
   display: inline-flex;
   align-items: center;
 }
 
-summary::marker {
+.aside-summary::marker {
   display: none;
 }
 
-summary:before {
+.aside-summary:before {
   content: "▶";
   font-size: 0.75rem;
   padding: 0 0.75rem 0 0.25rem;
   box-sizing: border-box;
 }
 
-details[open] summary:before {
+.aside[open] .aside_summary:before {
   content: "▼";
 }
 
-details > .in-depth {
+.aside > .aside-content {
   padding-left: 1rem;
 }
+/* }}}*/
diff --git a/src/html.rs b/src/html.rs
index 0a8a569..42df1b5 100644
--- a/src/html.rs
+++ b/src/html.rs
@@ -2,82 +2,99 @@
 
 use std::collections::HashMap;
 
+use anyhow::anyhow;
 use jotdown::Alignment;
 use jotdown::Container;
 use jotdown::Event;
 use jotdown::LinkType;
 use jotdown::ListKind;
 use jotdown::OrderedListNumbering::*;
-use jotdown::Render;
-use jotdown::RenderRef;
 use jotdown::SpanLinkType;
 
+use crate::template;
+use crate::template::Template;
+use crate::template::TemplateRenderer;
+
 // {{{ Renderer
 /// Render events into a string.
-pub fn render_to_string<'s, I>(events: I) -> String
+pub fn render_to_string<'s, I>(events: I) -> anyhow::Result<String>
 where
 	I: Iterator<Item = Event<'s>>,
 {
 	let mut s = String::new();
-	Renderer::default().push(events, &mut s).unwrap();
-	s
+	Renderer::new()?.push(events, &mut s)?;
+	Ok(s)
 }
 
 /// [`Render`] implementor that writes HTML output.
-#[derive(Clone, Default)]
-pub struct Renderer {}
-
-impl Render for Renderer {
-	fn push<'s, I, W>(&self, mut events: I, mut out: W) -> std::fmt::Result
-	where
-		I: Iterator<Item = Event<'s>>,
-		W: std::fmt::Write,
-	{
-		let mut w = Writer::new();
-		events.try_for_each(|e| w.render_event(&e, &mut out))?;
-		w.render_epilogue(&mut out)
-	}
+#[derive(Clone, Debug)]
+pub struct Renderer {
+	templates: BuiltinTempaltes,
 }
 
-impl RenderRef for Renderer {
-	fn push_ref<'s, E, I, W>(&self, mut events: I, mut out: W) -> std::fmt::Result
-	where
-		E: AsRef<Event<'s>>,
-		I: Iterator<Item = E>,
-		W: std::fmt::Write,
-	{
-		let mut w = Writer::new();
-		events.try_for_each(|e| w.render_event(e.as_ref(), &mut out))?;
-		w.render_epilogue(&mut out)
+impl Renderer {
+	pub fn new() -> anyhow::Result<Self> {
+		Ok(Self {
+			templates: BuiltinTempaltes::new()?,
+		})
+	}
+
+	pub fn push<'s>(
+		&self,
+		mut events: impl Iterator<Item = Event<'s>>,
+		mut out: impl std::fmt::Write,
+	) -> anyhow::Result<()> {
+		let mut w = Writer::new(self);
+		events.try_for_each(|e| w.render_event(&e, &mut out))?;
+		w.render_epilogue(&mut out)?;
+
+		Ok(())
+	}
+}
+// }}}
+// {{{ Bring in templates
+#[derive(Clone, Debug)]
+struct BuiltinTempaltes {
+	aside_template: Template,
+}
+
+impl BuiltinTempaltes {
+	fn new() -> anyhow::Result<Self> {
+		Ok(BuiltinTempaltes {
+			aside_template: template!("./templates/aside.html")?,
+		})
 	}
 }
 // }}}
 
 struct Writer<'s> {
 	list_tightness: Vec<bool>,
-	states: Vec<State>,
+	states: Vec<State<'s>>,
 	footnotes: Footnotes<'s>,
+	renderer: &'s Renderer,
 }
 
-#[derive(PartialEq, Eq, Debug, Clone, Copy)]
-enum State {
+#[derive(Debug, Clone)]
+enum State<'s> {
 	TextOnly,
 	Ignore,
 	Raw,
 	Math(bool),
+	Aside(TemplateRenderer<'s>),
 }
 
 impl<'s> Writer<'s> {
-	fn new() -> Self {
+	fn new(renderer: &'s Renderer) -> Self {
 		Self {
 			list_tightness: Vec::new(),
 			states: Vec::new(),
 			footnotes: Footnotes::default(),
+			renderer,
 		}
 	}
 
 	#[allow(clippy::single_match)]
-	fn render_event<W>(&mut self, e: &Event<'s>, mut out: W) -> std::fmt::Result
+	fn render_event<W>(&mut self, e: &Event<'s>, mut out: W) -> anyhow::Result<()>
 	where
 		W: std::fmt::Write,
 	{
@@ -101,7 +118,7 @@ impl<'s> Writer<'s> {
 				return Ok(());
 			}
 			Event::End(Container::LinkDefinition { .. }) => {
-				assert_eq!(self.states.last(), Some(&State::Ignore));
+				assert!(matches!(self.states.last(), Some(State::Ignore)));
 				self.states.pop();
 			}
 
@@ -116,9 +133,9 @@ impl<'s> Writer<'s> {
 			}
 			Event::End(Container::RawBlock { format } | Container::RawInline { format }) => {
 				if format == &"html" {
-					assert_eq!(self.states.last(), Some(&State::Raw));
+					assert!(matches!(self.states.last(), Some(State::Raw)));
 				} else {
-					assert_eq!(self.states.last(), Some(&State::Ignore));
+					assert!(matches!(self.states.last(), Some(State::Ignore)));
 				};
 
 				self.states.pop();
@@ -128,14 +145,14 @@ impl<'s> Writer<'s> {
 		}
 		// }}}
 
-		if self.states.last() == Some(&State::Ignore) {
+		if matches!(self.states.last(), Some(State::Ignore)) {
 			return Ok(());
 		}
 
 		match e {
 			// {{{ Container start
 			Event::Start(c, attrs) => {
-				if self.states.last() == Some(&State::TextOnly) {
+				if matches!(self.states.last(), Some(&State::TextOnly)) {
 					return Ok(());
 				}
 
@@ -200,6 +217,35 @@ impl<'s> Writer<'s> {
 					Container::Table => out.write_str("<table")?,
 					Container::TableRow { .. } => out.write_str("<tr")?,
 					Container::Section { .. } => out.write_str("<section")?,
+					Container::Div { class: "aside" } => {
+						let title = attrs.get_value("title").ok_or_else(|| {
+							anyhow!("Cannot find `title` attribute on `aside` element")
+						})?;
+						let character = attrs.get_value("character").ok_or_else(|| {
+							anyhow!("Cannot find `character` attribute on `aside` element")
+						})?;
+
+						let mut renderer =
+							TemplateRenderer::new(&self.renderer.templates.aside_template);
+
+						while let Some(label) = renderer.current(&mut out)? {
+							if label == "character" {
+								character
+									.parts()
+									.try_for_each(|part| write_attr(part, &mut out))?;
+								renderer.next(&mut out)?;
+							} else if label == "title" {
+								title
+									.parts()
+									.try_for_each(|part| write_attr(part, &mut out))?;
+								renderer.next(&mut out)?;
+							} else {
+								break;
+							}
+						}
+
+						self.states.push(State::Aside(renderer));
+					}
 					Container::Div { .. } => out.write_str("<div")?,
 					Container::Heading { level, .. } => write!(out, "<h{}", level)?,
 					Container::TableCell { head: false, .. } => out.write_str("<td")?,
@@ -220,85 +266,93 @@ impl<'s> Writer<'s> {
 					Container::LinkDefinition { .. } => return Ok(()),
 				}
 
-				// {{{ Write attributes
-				let mut id_written = false;
-				let mut class_written = false;
+				let mut write_attribs = true;
+				if matches!(c, Container::Div { class: "aside" }) {
+					write_attribs = false;
+				}
 
-				for (a, v) in attrs.unique_pairs() {
-					write!(out, r#" {}=""#, a)?;
-					v.parts().try_for_each(|part| write_attr(part, &mut out))?;
-					match a {
-						"class" => {
-							class_written = true;
-							write_class(c, true, &mut out)?;
+				if write_attribs {
+					// {{{ Write attributes
+					let mut id_written = false;
+					let mut class_written = false;
+
+					if write_attribs {
+						for (a, v) in attrs.unique_pairs() {
+							let is_class = a == "class";
+							let is_id = a == "id";
+							if (!is_id || !id_written) && (!is_class || !class_written) {
+								write!(out, r#" {}=""#, a)?;
+								v.parts().try_for_each(|part| write_attr(part, &mut out))?;
+								out.write_char('"')?;
+
+								id_written |= is_id;
+								class_written |= is_class;
+							};
 						}
-						"id" => id_written = true,
-						_ => {}
 					}
-					out.write_char('"')?;
-				}
-				// }}}
-				// {{{ Write default ids/classes
-				if let Container::Heading {
-					id,
-					has_section: false,
-					..
-				}
-				| Container::Section { id } = &c
-				{
-					if !id_written {
-						out.write_str(r#" id=""#)?;
-						write_attr(id, &mut out)?;
+					// }}}
+					// {{{ Write default ids/classes
+					if let Container::Heading {
+						id,
+						has_section: false,
+						..
+					}
+					| Container::Section { id } = &c
+					{
+						if !id_written {
+							out.write_str(r#" id=""#)?;
+							write_attr(id, &mut out)?;
+							out.write_char('"')?;
+						}
+					// TODO: do I not want this to add onto the provided class?
+					} else if (matches!(c, Container::Div { class } if !class.is_empty())
+						|| matches!(
+							c,
+							Container::Math { .. }
+								| Container::List {
+									kind: ListKind::Task(..),
+									..
+								} | Container::TaskListItem { .. }
+						)) && !class_written
+					{
+						out.write_str(r#" class=""#)?;
+						write_class(c, false, &mut out)?;
 						out.write_char('"')?;
 					}
-				// TODO: do I not want this to add onto the provided class?
-				} else if (matches!(c, Container::Div { class } if !class.is_empty())
-					|| matches!(
-						c,
-						Container::Math { .. }
-							| Container::List {
-								kind: ListKind::Task(..),
-								..
-							} | Container::TaskListItem { .. }
-					)) && !class_written
-				{
-					out.write_str(r#" class=""#)?;
-					write_class(c, false, &mut out)?;
-					out.write_char('"')?;
-				}
-				// }}}
+					// }}}
 
-				match c {
-					// {{{ Write css for aligning table cell text
-					Container::TableCell { alignment, .. }
-						if !matches!(alignment, Alignment::Unspecified) =>
-					{
-						let a = match alignment {
-							Alignment::Unspecified => unreachable!(),
-							Alignment::Left => "left",
-							Alignment::Center => "center",
-							Alignment::Right => "right",
-						};
-						write!(out, r#" style="text-align: {};">"#, a)?;
-					}
-					// }}}
-					// {{{ Write language for codeblock
-					Container::CodeBlock { language } => {
-						if language.is_empty() {
-							out.write_str("><code>")?;
-						} else {
-							out.write_str(r#"><code class="language-"#)?;
-							write_attr(language, &mut out)?;
-							out.write_str(r#"">"#)?;
+					match c {
+						// {{{ Write css for aligning table cell text
+						Container::TableCell { alignment, .. }
+							if !matches!(alignment, Alignment::Unspecified) =>
+						{
+							let a = match alignment {
+								Alignment::Unspecified => unreachable!(),
+								Alignment::Left => "left",
+								Alignment::Center => "center",
+								Alignment::Right => "right",
+							};
+							write!(out, r#" style="text-align: {};">"#, a)?;
 						}
+						// }}}
+						// {{{ Write language for codeblock
+						Container::CodeBlock { language } => {
+							if language.is_empty() {
+								out.write_str("><code>")?;
+							} else {
+								out.write_str(r#"><code class="language-"#)?;
+								write_attr(language, &mut out)?;
+								out.write_str(r#"">"#)?;
+							}
+						}
+						// }}}
+						Container::Image(..) => out.write_str(r#" alt=""#)?,
+						Container::Math { display } => {
+							out.write_str(r#">"#)?;
+							self.states.push(State::Math(*display));
+						}
+						_ => out.write_char('>')?,
 					}
-					// }}}
-					Container::Image(..) => out.write_str(r#" alt=""#)?,
-					Container::Math { display } => {
-						out.write_str(r#">"#)?;
-						self.states.push(State::Math(*display));
-					}
-					_ => out.write_char('>')?,
 				}
 
 				match &c {
@@ -313,13 +367,13 @@ impl<'s> Writer<'s> {
 			Event::End(c) => {
 				match &c {
 					Container::Image(..) => {
-						assert_eq!(self.states.last(), Some(&State::TextOnly));
+						assert!(matches!(self.states.last(), Some(State::TextOnly)));
 						self.states.pop();
 					}
 					_ => {}
 				}
 
-				if self.states.last() == Some(&State::TextOnly) {
+				if matches!(self.states.last(), Some(State::TextOnly)) {
 					return Ok(());
 				}
 
@@ -374,6 +428,15 @@ impl<'s> Writer<'s> {
 					Container::Table => out.write_str("</table>")?,
 					Container::TableRow { .. } => out.write_str("</tr>")?,
 					Container::Section { .. } => out.write_str("</section>")?,
+					Container::Div { class: "aside" } => {
+						let state = self.states.pop().unwrap();
+						let State::Aside(mut renderer) = state else {
+							panic!("Finished `aside` element without being in the `Aside` state.")
+						};
+
+						assert_eq!(renderer.current(&mut out)?, Some("content"));
+						renderer.finish(&mut out)?;
+					}
 					Container::Div { .. } => out.write_str("</div>")?,
 					Container::Heading { level, .. } => write!(out, "</h{}>", level)?,
 					Container::TableCell { head: false, .. } => out.write_str("</td>")?,
@@ -428,7 +491,7 @@ impl<'s> Writer<'s> {
 			// {{{ Footnote reference
 			Event::FootnoteReference(label) => {
 				let number = self.footnotes.reference(label);
-				if self.states.last() != Some(&State::TextOnly) {
+				if !matches!(self.states.last(), Some(State::TextOnly)) {
 					write!(
 						out,
 						r##"<a id="fnref{}" href="#fn{}" role="doc-noteref"><sup>{}</sup></a>"##,
@@ -468,10 +531,7 @@ impl<'s> Writer<'s> {
 	}
 
 	// {{{ Render epilogue
-	fn render_epilogue<W>(&mut self, mut out: W) -> std::fmt::Result
-	where
-		W: std::fmt::Write,
-	{
+	fn render_epilogue(&mut self, mut out: impl std::fmt::Write) -> anyhow::Result<()> {
 		if self.footnotes.reference_encountered() {
 			out.write_str("<section role=\"doc-endnotes\">")?;
 			out.write_str("<hr>")?;
@@ -545,24 +605,21 @@ where
 	Ok(())
 }
 
-fn write_text<W>(s: &str, out: W) -> std::fmt::Result
-where
-	W: std::fmt::Write,
-{
+#[inline]
+fn write_text(s: &str, out: impl std::fmt::Write) -> std::fmt::Result {
 	write_escape(s, false, out)
 }
 
-fn write_attr<W>(s: &str, out: W) -> std::fmt::Result
-where
-	W: std::fmt::Write,
-{
+#[inline]
+fn write_attr(s: &str, out: impl std::fmt::Write) -> std::fmt::Result {
 	write_escape(s, true, out)
 }
 
-fn write_escape<W>(mut s: &str, escape_quotes: bool, mut out: W) -> std::fmt::Result
-where
-	W: std::fmt::Write,
-{
+fn write_escape(
+	mut s: &str,
+	escape_quotes: bool,
+	mut out: impl std::fmt::Write,
+) -> std::fmt::Result {
 	let mut ent = "";
 	while let Some(i) = s.find(|c| {
 		match c {
diff --git a/src/main.rs b/src/main.rs
index bed9ece..486632f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,60 @@
+use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::str::FromStr;
+
+use anyhow::Context;
+use html::Renderer;
+use template::TemplateRenderer;
+
 mod html;
+mod template;
 mod tex;
-fn main() {
-	let djot_input = std::fs::read_to_string("content/arcaea.dj").unwrap();
-	let events = jotdown::Parser::new(&djot_input);
-	let html = crate::html::render_to_string(events);
-	println!("{html}");
+
+fn copy_recursively(from: &Path, to: &Path) -> anyhow::Result<()> {
+	Command::new("cp").arg("-r").arg(from).arg(to).output()?;
+
+	Ok(())
+}
+
+fn main() -> anyhow::Result<()> {
+	let dist_path = PathBuf::from_str("dist")?;
+	let public_path = PathBuf::from_str("public")?;
+
+	if dist_path.exists() {
+		std::fs::remove_dir_all(&dist_path).with_context(|| "Cannot delete `dist` directory")?;
+	}
+
+	std::fs::create_dir(&dist_path).with_context(|| "Cannot create `dist` directory")?;
+
+	for p in std::fs::read_dir(public_path)? {
+		copy_recursively(&p?.path(), &dist_path)
+			.with_context(|| "Cannot copy `public` -> `dist`")?;
+	}
+
+	// {{{ Generate contents
+	let djot_input = std::fs::read_to_string("content/arcaea.dj").unwrap();
+	let mut out = String::new();
+	let page_template = template!("templates/page.html")?;
+	let mut page_renderer = TemplateRenderer::new(&page_template);
+
+	while let Some(label) = page_renderer.next(&mut out)? {
+		if label == "content" {
+			let events = jotdown::Parser::new(&djot_input);
+			let html = Renderer::new()?;
+			html.push(events, &mut out)?;
+		} else {
+			break;
+		}
+	}
+
+	page_renderer.finish(&mut out)?;
+	// }}}
+
+	let posts_dir = dist_path.join("posts");
+	std::fs::create_dir(&posts_dir).with_context(|| "Cannot create `dist/posts` directory")?;
+
+	std::fs::write(posts_dir.join("arcaea.html"), out)
+		.with_context(|| "Failed to write `arcaea.html` post")?;
+
+	Ok(())
 }
diff --git a/src/template.rs b/src/template.rs
new file mode 100644
index 0000000..a206ad4
--- /dev/null
+++ b/src/template.rs
@@ -0,0 +1,157 @@
+use std::fmt::Write;
+
+use anyhow::bail;
+
+// {{{ Templates & stops
+#[derive(Clone, Debug)]
+struct Stop {
+	label: String,
+	start: usize,
+	length: usize,
+}
+
+#[derive(Clone, Debug)]
+pub struct Template {
+	text: String,
+	stops: Vec<Stop>,
+}
+
+impl Template {
+	// {{{ Parse template
+	#[allow(clippy::iter_nth_zero)]
+	pub fn parse(text: String) -> anyhow::Result<Template> {
+		let mut stops = Vec::new();
+
+		let open_stop = "{{";
+		let close_stop = "}}";
+
+		let mut current_stop: Option<Stop> = None;
+		let mut prev_ix = None;
+		for (ix, c) in text.char_indices() {
+			if let Some(prev) = prev_ix {
+				// This char, together with the previous one
+				let last_two = &text[prev..=ix];
+				if close_stop == last_two {
+					if let Some(mut stop) = current_stop.take() {
+						stop.label.pop().unwrap();
+						// I think this is safe, as } is ascii
+						stop.length = ix + 1 - stop.start;
+						stops.push(stop);
+					}
+				} else if open_stop == last_two && current_stop.is_none() {
+					current_stop = Some(Stop {
+						label: String::new(),
+						start: prev,
+						length: 0,
+					});
+				} else if let Some(stop) = current_stop.as_mut() {
+					stop.label.write_char(c)?;
+				}
+			}
+
+			prev_ix = Some(ix);
+		}
+
+		Ok(Template { text, stops })
+	}
+	// }}}
+}
+// }}}
+// {{{ Template rendering
+#[derive(PartialEq, Eq, Clone, Copy, Debug)]
+enum RendererState {
+	Started,
+	InStop(usize),
+	Finished,
+}
+
+#[derive(Clone, Debug)]
+pub struct TemplateRenderer<'a> {
+	template: &'a Template,
+	state: RendererState,
+}
+
+impl<'a> TemplateRenderer<'a> {
+	#[inline]
+	pub fn new(template: &'a Template) -> Self {
+		Self {
+			template,
+			state: RendererState::Started,
+		}
+	}
+
+	/// Get the current placeholder label
+	pub fn current(&mut self, w: impl std::fmt::Write) -> anyhow::Result<Option<&'a str>> {
+		let current_label = match self.state {
+			RendererState::Started => self.next(w)?,
+			RendererState::InStop(ix) => Some(self.template.stops[ix].label.as_str()),
+			RendererState::Finished => None,
+		};
+
+		Ok(current_label)
+	}
+
+	/// Attempt to finish rendering.
+	pub fn finish(mut self, w: impl std::fmt::Write) -> anyhow::Result<()> {
+		if let Some(label) = self.next(w)? {
+			bail!("Attempting to finish template rendering before label `{label}` was handled");
+		}
+
+		Ok(())
+	}
+
+	// {{{ Advance to the next placeholder
+	/// Move onto the next placeholder
+	pub fn next(&mut self, mut w: impl std::fmt::Write) -> anyhow::Result<Option<&'a str>> {
+		if self.state == RendererState::Finished {
+			return Ok(None);
+		}
+
+		let (_, current_pos) = self.current_stop_range();
+
+		let next_stop_ix = match self.state {
+			RendererState::Started => 0,
+			RendererState::InStop(stop_ix) => stop_ix + 1,
+			RendererState::Finished => unreachable!(),
+		};
+
+		self.state = if next_stop_ix < self.template.stops.len() {
+			RendererState::InStop(next_stop_ix)
+		} else {
+			RendererState::Finished
+		};
+
+		let (next_pos, _) = self.current_stop_range();
+		w.write_str(&self.template.text[current_pos..next_pos])?;
+
+		let current_label = match self.state {
+			RendererState::InStop(ix) => Some(self.template.stops[ix].label.as_str()),
+			_ => None,
+		};
+
+		Ok(current_label)
+	}
+
+	fn current_stop_range(&self) -> (usize, usize) {
+		match self.state {
+			RendererState::Started => (0, 0),
+			RendererState::InStop(stop_ix) => {
+				let stop = &self.template.stops[stop_ix];
+				(stop.start, stop.start + stop.length)
+			}
+			RendererState::Finished => (self.template.text.len(), self.template.text.len()),
+		}
+	}
+
+	// }}}
+}
+// }}}
+// {{{ Macro
+#[macro_export]
+macro_rules! template {
+	($path:literal) => {{
+		static TEMPLATE_TEXT: &str = include_str!($path);
+		$crate::template::Template::parse(TEMPLATE_TEXT.to_owned())
+	}};
+}
+// }}}
diff --git a/src/templates/aside.html b/src/templates/aside.html
new file mode 100644
index 0000000..4a3113a
--- /dev/null
+++ b/src/templates/aside.html
@@ -0,0 +1,11 @@
+<details class="aside">
+  <summary class="aside-summary">
+    <img
+      class="aside-icon"
+      alt="{{character}}"
+      src="/assets/icons/{{character}}.webp"
+    />
+    <span class="aside-title">{{title}}</span>
+  </summary>
+  <div class="aside-content">{{content}}</div>
+</details>
diff --git a/public/index.html b/src/templates/page.html
similarity index 86%
rename from public/index.html
rename to src/templates/page.html
index 3bb6d7a..d9d912c 100644
--- a/public/index.html
+++ b/src/templates/page.html
@@ -9,7 +9,7 @@
     <meta name="theme-color" content="#000000" />
     <title>Moonythm</title>
 
-    <link rel="stylesheet" href="styles.css" />
+    <link rel="stylesheet" href="/styles.css" />
 
     <!-- MathML -->
     <link
@@ -25,6 +25,6 @@
     />
   </head>
   <body>
-    $CONTENT
+    <div id="page-content">{{content}}</div>
   </body>
 </html>