about summary refs log tree commit diff
diff options
context:
space:
mode:
authorgennyble <gen@nyble.dev>2026-09-21 14:23:28 -0500
committergennyble <gen@nyble.dev>2026-09-21 14:23:28 -0500
commitde0e60603d3e470befad49f2ec59778b5d1b5d59 (patch)
tree861edd0aaa1745e97cca926262e0eb73448da83c
parentbe872aa0cd61ea16b95eddcd68c6a72f0fe3af96 (diff)
splorm, i wake and breathe
-rw-r--r--splorm/Cargo.toml21
-rw-r--r--splorm/README.md2
-rw-r--r--splorm/TEMPLATES.md42
-rw-r--r--splorm/splorm.conf6
-rw-r--r--splorm/src/config.rs83
-rw-r--r--splorm/src/fs/mod.rs28
-rw-r--r--splorm/src/fs/request_path.rs171
-rw-r--r--splorm/src/handler/mod.rs25
-rw-r--r--splorm/src/handler/staticfile.rs52
-rw-r--r--splorm/src/handler/templated.rs131
-rw-r--r--splorm/src/main.rs39
-rw-r--r--splorm/src/runtime_error.rs190
-rw-r--r--splorm/src/state.rs2
13 files changed, 792 insertions, 0 deletions
diff --git a/splorm/Cargo.toml b/splorm/Cargo.toml
new file mode 100644
index 0000000..d5f7f34
--- /dev/null
+++ b/splorm/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "splorm"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies.tokio]
+workspace = true
+features = ["net", "rt-multi-thread", "fs"]
+
+[dependencies]
+# Here for it's ReaderStream which we use while streaming files back to the browser
+tokio-util = { version = "0.7.10", features = ["io"] }
+
+camino.workspace = true
+confindent.workspace = true
+bempline.workspace = true
+axum.workspace = true
+
+# subcrates
+paige.workspace = true
+timeparse.workspace = true
diff --git a/splorm/README.md b/splorm/README.md
new file mode 100644
index 0000000..8a0af2f
--- /dev/null
+++ b/splorm/README.md
@@ -0,0 +1,2 @@
+**splorm**, i wake and breathe  
+*(an after-awake)*
diff --git a/splorm/TEMPLATES.md b/splorm/TEMPLATES.md
new file mode 100644
index 0000000..9fcde82
--- /dev/null
+++ b/splorm/TEMPLATES.md
@@ -0,0 +1,42 @@
+what variables are available in templates?
+
+**description** (default: none)  
+description of the page for OpenGraph tags
+
+**title** (default: filename)  
+the `<title>` of the page
+
+⁂
+
+**skip-path** (default: none)  
+number of path components to remove while displaying the **`path`** pattern.
+
+### patterns
+**pathnav**  
+creates a collection of navigation links linking to directories under the page.
+
+template variables:
+- `pathnav_link`: link to navigate to
+- `pathnav_name`: displayed name of the link
+
+frontmatter:
+- `skip-path`: how many immediate parents to remove from the path
+
+**published**  
+inserts an html element showing the time a page was published
+
+template variables:
+- `published_human`: time formatted for a human to read
+- `published_machine`: time formatted for a machine to read
+
+frontmatter:
+- `published`: time page was published
+
+**styles**  
+inserts `<link rel="stylesheet">` into the head of the document.
+
+template variables:
+- `style`: path to a stylesheet
+
+frontmatter:
+- `style`: path to a stylesheet
diff --git a/splorm/splorm.conf b/splorm/splorm.conf
new file mode 100644
index 0000000..b076d6e
--- /dev/null
+++ b/splorm/splorm.conf
@@ -0,0 +1,6 @@
+Network
+	Port 25728
+
+Serving /Users/gen/src/inf/served
+	Templates /Users/gen/src/inf/templates
+		Default minimal
diff --git a/splorm/src/config.rs b/splorm/src/config.rs
new file mode 100644
index 0000000..79eb115
--- /dev/null
+++ b/splorm/src/config.rs
@@ -0,0 +1,83 @@
+use std::net::{SocketAddr, ToSocketAddrs};
+
+use camino::{Utf8Path, Utf8PathBuf};
+use confindent::Confindent;
+
+use crate::fs::{RequestPath, SafePathBuf};
+
+pub struct Config {
+	port: u16,
+	settings: Settings,
+}
+
+impl Config {
+	pub fn get(path: &Utf8Path) -> Config {
+		let confi = Confindent::from_file(path).unwrap();
+
+		let network = confi.child("Network").unwrap();
+		let port = network.child_parse("Port").unwrap();
+
+		let serving = confi.child("Serving").unwrap();
+		let webroot: Utf8PathBuf = serving.parse().unwrap();
+
+		let templates_sec = serving.child("Templates").unwrap();
+		let templates: Utf8PathBuf = templates_sec.parse().unwrap();
+		let default_template = templates_sec.child_owned("Default").unwrap();
+
+		Self {
+			port,
+			settings: Settings {
+				default_template,
+				template_dir: templates, // webroot.join(templates),
+
+				webroot,
+			},
+		}
+	}
+
+	pub fn listen_address(&self) -> SocketAddr {
+		("0.0.0.0", self.port)
+			.to_socket_addrs()
+			.unwrap()
+			.next()
+			.unwrap()
+	}
+
+	pub fn settings(&self) -> &Settings {
+		&self.settings
+	}
+}
+
+/// Settings are values that need to live for a long time. They enter into
+/// SplormState so we can reference them later
+#[derive(Clone, Debug)]
+pub struct Settings {
+	webroot: Utf8PathBuf,
+
+	default_template: String,
+	template_dir: Utf8PathBuf,
+}
+
+impl Settings {
+	pub fn root(&self) -> &Utf8Path {
+		&self.webroot
+	}
+
+	pub fn template_dir(&self) -> &Utf8Path {
+		&self.template_dir
+	}
+
+	pub fn default_template_name(&self) -> &str {
+		&self.default_template
+	}
+
+	pub fn default_template_path(&self) -> SafePathBuf {
+		RequestPath::verify_safe(
+			self.template_dir(),
+			&format!("{}.html", self.default_template),
+		)
+		.unwrap()
+		.system_path()
+		.to_owned()
+	}
+}
diff --git a/splorm/src/fs/mod.rs b/splorm/src/fs/mod.rs
new file mode 100644
index 0000000..032aaed
--- /dev/null
+++ b/splorm/src/fs/mod.rs
@@ -0,0 +1,28 @@
+mod request_path;
+
+pub use request_path::{Mime, Mimetype, RequestPath, SafePath, SafePathBuf};
+use tokio::fs::File;
+
+use crate::RuntimeError;
+
+pub async fn open_file<'p>(path: SafePath<'p>) -> Result<File, RuntimeError> {
+	File::open(*path)
+		.await
+		.map_err(|err| RuntimeError::file_error(err, path.to_path_buf()))
+}
+
+pub async fn read_file<'p>(path: SafePath<'p>) -> Result<Vec<u8>, RuntimeError> {
+	let system_path = *path;
+	match tokio::fs::read(system_path).await {
+		Err(err) => Err(RuntimeError::file_error(err, system_path.to_path_buf())),
+		Ok(vec) => Ok(vec),
+	}
+}
+
+pub async fn read_file_to_string<'p>(path: SafePath<'p>) -> Result<String, RuntimeError> {
+	let system_path = *path;
+	match tokio::fs::read_to_string(system_path).await {
+		Err(err) => Err(RuntimeError::file_error(err, system_path.to_path_buf())),
+		Ok(str) => Ok(str),
+	}
+}
diff --git a/splorm/src/fs/request_path.rs b/splorm/src/fs/request_path.rs
new file mode 100644
index 0000000..d065808
--- /dev/null
+++ b/splorm/src/fs/request_path.rs
@@ -0,0 +1,171 @@
+use std::{fs::Metadata, ops::Deref};
+
+use camino::{Utf8Path, Utf8PathBuf};
+
+use crate::RuntimeError;
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct RequestPath {
+	request_path: String,
+	system_path: Utf8PathBuf,
+}
+
+impl RequestPath {
+	/// Use the `root` to verify a received `path` is safe to access and serve.
+	/// This function will canonicalize it and return a `RequestPath` if the file
+	/// exists and is within the root directory.
+	pub fn verify_safe(root: &Utf8Path, path: &str) -> Result<Self, RuntimeError> {
+		let system_unsafe_path = root.join(path);
+		match system_unsafe_path.canonicalize_utf8() {
+			Ok(canon) => match canon.strip_prefix(root) {
+				Err(_e) => Err(RuntimeError::outside_root(path.to_owned(), canon)),
+				Ok(path) => Ok(RequestPath {
+					request_path: path.to_string(),
+					system_path: Self::file_or_dirfile(canon)?,
+				}),
+			},
+			Err(_io) => Err(RuntimeError::not_found(
+				path.to_string(),
+				system_unsafe_path,
+			)),
+		}
+	}
+
+	fn file_or_dirfile(path: Utf8PathBuf) -> Result<Utf8PathBuf, RuntimeError> {
+		let meta = path
+			.metadata()
+			.map_err(|e| RuntimeError::file_error(e, path.clone()))?;
+
+		if meta.is_dir() {
+			let name = path.file_stem().unwrap();
+			let dirfile = path.join(format!("{name}.html"));
+
+			if dirfile.exists() {
+				return Ok(dirfile);
+			}
+		}
+
+		Ok(path)
+	}
+
+	/// The web path from the request, after canonicalization.
+	pub fn web_path(&self) -> &str {
+		&self.request_path
+	}
+
+	/// A verified-safe system path.
+	pub fn system_path<'p>(&'p self) -> SafePath<'p> {
+		SafePath(self.system_path.as_path())
+	}
+
+	pub fn guess_mime(&self) -> Mime {
+		let stem = self.system_path.file_stem().unwrap_or_default();
+		let ext = self.system_path.extension().unwrap_or_default();
+
+		match ext {
+			// Text
+			"css" => "text/css".as_mime(Mimetype::Text),
+			"html" => "text/html".as_mime(Mimetype::Text),
+			"js" => "text/javascript".as_mime(Mimetype::Text),
+			"txt" => "text/plain".as_mime(Mimetype::Text),
+			"xml" if stem.ends_with("atom") => "application/atom+xml".as_mime(Mimetype::Text),
+			"xml" => "application/xml".as_mime(Mimetype::Text),
+
+			// Multimedia
+			"gif" => "image/gif".as_mime(Mimetype::Media),
+			"jpg" | "jpeg" => "image/jpeg".as_mime(Mimetype::Media),
+			"mp4" => "video/mp4".as_mime(Mimetype::Media),
+			"png" => "image/png".as_mime(Mimetype::Media),
+			_ => "".as_mime(Mimetype::Media),
+		}
+	}
+}
+
+/// A verified-safe system path.
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct SafePath<'p>(&'p Utf8Path);
+
+impl<'p> SafePath<'p> {
+	pub fn to_owned(&self) -> SafePathBuf {
+		SafePathBuf(self.0.to_path_buf())
+	}
+
+	pub fn metadata(&self) -> Result<Metadata, RuntimeError> {
+		self.0
+			.metadata()
+			.map_err(|err| RuntimeError::file_error(err, self.0.to_path_buf()))
+	}
+}
+
+impl<'p> Deref for SafePath<'p> {
+	type Target = &'p Utf8Path;
+
+	fn deref(&self) -> &Self::Target {
+		&self.0
+	}
+}
+
+impl<'p> AsRef<Utf8Path> for SafePath<'p> {
+	fn as_ref(&self) -> &Utf8Path {
+		self.0
+	}
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SafePathBuf(Utf8PathBuf);
+
+impl SafePathBuf {
+	pub fn as_path<'p>(&'p self) -> SafePath<'p> {
+		SafePath(&self.0)
+	}
+}
+
+impl Deref for SafePathBuf {
+	type Target = Utf8Path;
+
+	fn deref(&self) -> &Self::Target {
+		&self.0
+	}
+}
+
+impl AsRef<Utf8PathBuf> for SafePathBuf {
+	fn as_ref(&self) -> &Utf8PathBuf {
+		&self.0
+	}
+}
+
+pub struct Mime {
+	string: &'static str,
+	mimetype: Mimetype,
+}
+
+impl Mime {
+	pub fn mimetype(&self) -> Mimetype {
+		self.mimetype
+	}
+}
+
+impl AsRef<str> for Mime {
+	fn as_ref(&self) -> &str {
+		self.string
+	}
+}
+
+pub trait AsMime {
+	fn as_mime(self, mimetype: Mimetype) -> Mime;
+}
+
+impl AsMime for &'static str {
+	fn as_mime(self, mimetype: Mimetype) -> Mime {
+		Mime {
+			string: self,
+			mimetype,
+		}
+	}
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum Mimetype {
+	Text,
+	Media,
+}
diff --git a/splorm/src/handler/mod.rs b/splorm/src/handler/mod.rs
new file mode 100644
index 0000000..0089c48
--- /dev/null
+++ b/splorm/src/handler/mod.rs
@@ -0,0 +1,25 @@
+use axum::{Extension, extract::Path, response::Response};
+
+mod staticfile;
+mod templated;
+
+use crate::{RuntimeError, config::Settings, fs::RequestPath};
+
+pub async fn index(Extension(settings): Extension<Settings>) -> Result<Response, RuntimeError> {
+	println!("index!");
+	let rqp = RequestPath::verify_safe(settings.root(), "home.html")?;
+	templated::send_file(rqp, &settings).await
+}
+
+pub async fn static_path(
+	Extension(settings): Extension<Settings>,
+	Path(path): Path<String>,
+) -> Result<Response, RuntimeError> {
+	println!("{path}");
+	let rqp = RequestPath::verify_safe(settings.root(), &path)?;
+	if rqp.guess_mime().as_ref() == "text/html" {
+		templated::send_file(rqp, &settings).await
+	} else {
+		staticfile::send_or_stream_file(rqp).await
+	}
+}
diff --git a/splorm/src/handler/staticfile.rs b/splorm/src/handler/staticfile.rs
new file mode 100644
index 0000000..8f26251
--- /dev/null
+++ b/splorm/src/handler/staticfile.rs
@@ -0,0 +1,52 @@
+use axum::{body::Body, response::Response};
+use tokio_util::io::ReaderStream;
+
+use crate::{
+	RuntimeError,
+	fs::{self, Mimetype, RequestPath},
+};
+
+const STREAM_AFTER: u64 = 1024 * 1024 * 5;
+pub async fn send_or_stream_file(path: RequestPath) -> Result<Response, RuntimeError> {
+	let meta = path.system_path().metadata()?;
+
+	if meta.len() > STREAM_AFTER {
+		stream_file(path).await
+	} else {
+		send_file(path).await
+	}
+}
+
+pub async fn send_file(path: RequestPath) -> Result<Response, RuntimeError> {
+	let mime = path.guess_mime();
+
+	let body = match mime.mimetype() {
+		Mimetype::Text => {
+			let text = fs::read_file_to_string(path.system_path()).await?;
+			Body::from(text)
+		}
+		Mimetype::Media => {
+			let data = fs::read_file(path.system_path()).await?;
+			Body::from(data)
+		}
+	};
+
+	Response::builder()
+		.status(200)
+		.header("Content-Type", mime.as_ref())
+		.body(body)
+		.map_err(|_| RuntimeError::unknown())
+}
+
+pub async fn stream_file(path: RequestPath) -> Result<Response, RuntimeError> {
+	let mime = path.guess_mime();
+
+	let file = fs::open_file(path.system_path()).await?;
+	let stream = ReaderStream::new(file);
+
+	Response::builder()
+		.status(200)
+		.header("Content-Type", mime.as_ref())
+		.body(Body::from_stream(stream))
+		.map_err(|_| RuntimeError::unknown())
+}
diff --git a/splorm/src/handler/templated.rs b/splorm/src/handler/templated.rs
new file mode 100644
index 0000000..596210d
--- /dev/null
+++ b/splorm/src/handler/templated.rs
@@ -0,0 +1,131 @@
+use std::str::FromStr;
+
+use axum::{body::Body, response::Response};
+use bempline::{Document, Options, options::IncludeMethod, variables};
+use paige::{Frontmatter, Templated};
+use timeparse::OffsetDateTime;
+
+use crate::{
+	RuntimeError,
+	config::Settings,
+	fs::{self, RequestPath, SafePath, SafePathBuf},
+};
+
+pub async fn send_file(path: RequestPath, settings: &Settings) -> Result<Response, RuntimeError> {
+	let safe = path.system_path().to_owned();
+	let string = fs::read_file_to_string(path.system_path()).await?;
+	let template = Templated::from_str(&string)
+		.map_err(|_| RuntimeError::not_a_template(path.system_path().to_path_buf()))?;
+	let tvars = TemplateVariables::build(safe.as_path(), &template.frontmatter);
+
+	let pt_path = match tvars.template {
+		Some(name) => {
+			let path = RequestPath::verify_safe(settings.template_dir(), &format!("{name}.html"))?;
+			path.system_path().to_owned()
+		}
+		None => settings.default_template_path(),
+	};
+
+	let mut page_template = get_template(pt_path).await?;
+	tvars.apply_title(&mut page_template);
+	tvars.apply_styles(&mut page_template);
+	tvars.apply_published(&mut page_template);
+	tvars.apply_pathnav(&mut page_template, path);
+	page_template.set("main", template.content);
+
+	Response::builder()
+		.status(200)
+		.header("content-type", "text/html")
+		.body(Body::from(page_template.compile()))
+		.map_err(|_| RuntimeError::Unknown)
+}
+
+async fn get_template(path: SafePathBuf) -> Result<Document, RuntimeError> {
+	let options = Options::new().include_path(IncludeMethod::Path(path.as_std_path().to_owned()));
+	let str = fs::read_file_to_string(path.as_path()).await?;
+
+	Document::from_str(str, options)
+		.map_err(|perr| RuntimeError::template_parse_error(perr, path.to_path_buf()))
+}
+
+pub struct TemplateVariables<'fm> {
+	template: Option<&'fm str>,
+
+	style: Vec<&'fm str>,
+
+	title: &'fm str,
+	description: Option<&'fm str>,
+	published: Option<OffsetDateTime>,
+
+	skip_path: Option<usize>,
+}
+
+impl<'fm> TemplateVariables<'fm> {
+	pub fn build(safe: SafePath<'fm>, fm: &'fm Frontmatter) -> Self {
+		let filename = safe.file_name().unwrap_or("unknown");
+
+		Self {
+			template: fm.get("template"),
+			title: fm.get("title").unwrap_or_else(|| filename),
+			style: fm.get_many("style"),
+			description: fm.get("description"),
+			published: fm
+				.get("published")
+				.map(|raw| timeparse::parse(raw).ok())
+				.flatten(),
+			skip_path: fm.get("skip-path").map(|s| s.parse().ok()).flatten(),
+		}
+	}
+
+	pub fn apply_styles(&self, doc: &mut Document) {
+		if let Some(styles) = doc.get_pattern("styles") {
+			self.style.iter().for_each(|style| {
+				let mut cloned = styles.clone();
+				cloned.set("style", style);
+				doc.set_pattern(cloned);
+			});
+		}
+	}
+
+	pub fn apply_title(&self, doc: &mut Document) {
+		doc.set("title", self.title);
+	}
+
+	pub fn apply_published(&self, doc: &mut Document) {
+		if let Some(mut published_pattern) = doc.get_pattern("published") {
+			if let Some(published) = self.published.as_ref() {
+				let published_human = timeparse::format::human_long(published);
+				let published_machine = timeparse::format::iso8601(published);
+
+				variables!(published_pattern, published_human, published_machine);
+				doc.set_pattern(published_pattern);
+			}
+		}
+	}
+
+	pub fn apply_pathnav(&self, doc: &mut Document, path: RequestPath) {
+		let webpath = path.web_path();
+
+		let mut components: Vec<&str> = webpath.split('/').collect();
+		// we don't want the name itself
+		components.pop();
+
+		if let Some(path_pattern) = doc.get_pattern("pathnav") {
+			let mut home = path_pattern.clone();
+			home.set("pathnav_link", "/");
+			home.set("pathnav_name", "home");
+			doc.set_pattern(home);
+
+			let count = components.len().saturating_sub(self.skip_path.unwrap_or(0));
+			let mut link = String::from("/");
+			for part in components.iter().take(count) {
+				link.push_str(&format!("{part}/"));
+
+				let mut pat = path_pattern.clone();
+				pat.set("pathnav_link", &link);
+				pat.set("pathnav_name", part);
+				doc.set_pattern(pat);
+			}
+		}
+	}
+}
diff --git a/splorm/src/main.rs b/splorm/src/main.rs
new file mode 100644
index 0000000..d35d1dc
--- /dev/null
+++ b/splorm/src/main.rs
@@ -0,0 +1,39 @@
+use axum::{Extension, Router, routing::get};
+use camino::Utf8PathBuf;
+use tokio::{net::TcpListener, runtime::Builder};
+
+use crate::{config::Config, state::SplormState};
+
+mod config;
+pub mod fs;
+mod handler;
+mod runtime_error;
+mod state;
+
+pub use runtime_error::{InvalidPathReason, RuntimeError};
+
+fn main() {
+	let config = Utf8PathBuf::from("splorm.conf");
+	let conf = Config::get(&config);
+
+	// Create and spawn the tokio multithreaded runtime. Execution stays
+	// here until async_main() returns.
+	let rt = Builder::new_multi_thread().enable_all().build().unwrap();
+	rt.block_on(async_main(conf))
+}
+
+async fn async_main(conf: Config) {
+	// Setup the routing
+	let bare_routes = Router::new()
+		.route("/", get(handler::index))
+		.route("/{*path}", get(handler::static_path));
+
+	// Apply Extensions and state
+	let routes = bare_routes
+		.layer(Extension(conf.settings().clone()))
+		.with_state(SplormState {});
+
+	// Create our listener and start axum serving and routing
+	let listener = TcpListener::bind(conf.listen_address()).await.unwrap();
+	axum::serve(listener, routes).await.unwrap();
+}
diff --git a/splorm/src/runtime_error.rs b/splorm/src/runtime_error.rs
new file mode 100644
index 0000000..99aca45
--- /dev/null
+++ b/splorm/src/runtime_error.rs
@@ -0,0 +1,190 @@
+use std::{fmt, io::ErrorKind};
+
+use axum::{
+	body::Body,
+	response::{IntoResponse, Response},
+};
+use camino::Utf8PathBuf;
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum InvalidPathReason {
+	OutsideRoot,
+	NotFound,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum FileErrorReason {
+	PermissionDenied,
+	NotFound,
+	Unknown,
+}
+
+impl fmt::Display for FileErrorReason {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		match self {
+			Self::NotFound => write!(f, "file not found"),
+			Self::PermissionDenied => write!(f, "permission denied"),
+			Self::Unknown => write!(f, "unknown file error"),
+		}
+	}
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum TemplateErrorReason {
+	NotATemplate,
+	ParseError,
+}
+
+#[derive(Debug)]
+pub enum RuntimeError {
+	InvalidPath {
+		reason: InvalidPathReason,
+		request_path: String,
+		system_path: Utf8PathBuf,
+	},
+	FileError {
+		reason: FileErrorReason,
+		source: std::io::Error,
+		system_path: Utf8PathBuf,
+	},
+	TemplateError {
+		reason: TemplateErrorReason,
+		source: Option<bempline::ParseError>,
+		system_path: Utf8PathBuf,
+	},
+	Unknown,
+}
+
+impl RuntimeError {
+	pub fn outside_root(request_path: String, system_path: Utf8PathBuf) -> Self {
+		Self::InvalidPath {
+			reason: InvalidPathReason::OutsideRoot,
+			request_path,
+			system_path,
+		}
+	}
+
+	pub fn not_found(request_path: String, system_path: Utf8PathBuf) -> Self {
+		Self::InvalidPath {
+			reason: InvalidPathReason::NotFound,
+			request_path,
+			system_path,
+		}
+	}
+
+	pub fn file_error(err: std::io::Error, system_path: Utf8PathBuf) -> Self {
+		let reason = match err.kind() {
+			ErrorKind::PermissionDenied => FileErrorReason::PermissionDenied,
+			ErrorKind::NotFound => FileErrorReason::NotFound,
+			_ => FileErrorReason::Unknown,
+		};
+
+		Self::FileError {
+			reason,
+			source: err,
+			system_path,
+		}
+	}
+
+	pub fn not_a_template(system_path: Utf8PathBuf) -> Self {
+		Self::TemplateError {
+			reason: TemplateErrorReason::NotATemplate,
+			source: None,
+			system_path,
+		}
+	}
+
+	pub fn template_parse_error(err: bempline::ParseError, system_path: Utf8PathBuf) -> Self {
+		Self::TemplateError {
+			reason: TemplateErrorReason::ParseError,
+			source: Some(err),
+			system_path,
+		}
+	}
+
+	pub fn unknown() -> Self {
+		RuntimeError::Unknown
+	}
+
+	pub fn status_code(&self) -> u16 {
+		match self {
+			Self::InvalidPath { .. } => 404,
+			Self::FileError { reason, .. } => match reason {
+				FileErrorReason::NotFound => 404,
+				FileErrorReason::PermissionDenied => 404,
+				FileErrorReason::Unknown => 500,
+			},
+			Self::TemplateError { .. } => 500,
+			Self::Unknown => 500,
+		}
+	}
+}
+
+struct PresentableRuntimeError(RuntimeError);
+impl fmt::Display for PresentableRuntimeError {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		macro_rules! estr {
+			(404) => {
+				write!(f, "the requested file could not be found")
+			};
+			(404 $webpath:ident) => {
+				write!(f, "file not found: '{}'", $webpath)
+			};
+			(500) => {
+				write!(f, "an unknown error occured!")
+			};
+		}
+
+		match &self.0 {
+			RuntimeError::InvalidPath {
+				request_path,
+				reason,
+				system_path,
+			} => {
+				println!("invalid_path = {request_path} ({system_path}) // {reason:?}");
+				estr!(404 request_path)
+			}
+			RuntimeError::FileError {
+				reason,
+				system_path,
+				..
+			} => match reason {
+				FileErrorReason::NotFound | FileErrorReason::PermissionDenied => {
+					println!("404 = {system_path}");
+					estr!(404)
+				}
+				FileErrorReason::Unknown => estr!(500),
+			},
+			RuntimeError::TemplateError { .. } => estr!(500),
+			RuntimeError::Unknown => estr!(500),
+		}
+	}
+}
+
+impl IntoResponse for RuntimeError {
+	fn into_response(self) -> Response {
+		let status = self.status_code();
+		let presentable = PresentableRuntimeError(self);
+		make_basic_error(status, presentable)
+	}
+}
+
+fn make_basic_error<S: std::fmt::Display>(status: u16, msg: S) -> Response {
+	let error_str = format!(
+		"<html>\n\
+		\t<head><title>{status}</title></head>\n\
+		\t<body style='width: 20rem; padding: 0px; margin: 2rem;'>\n\
+		\t\t<h1>{status}</h1>\n\
+		\t\t<hr/>\n\
+		\t\t<p>{msg}</p>\n\
+		\t</body>\n\
+		</html>"
+	);
+
+	Response::builder()
+		.status(status)
+		.header("content-type", "text/html")
+		.body(Body::from(error_str))
+		.unwrap()
+		.into_response()
+}
diff --git a/splorm/src/state.rs b/splorm/src/state.rs
new file mode 100644
index 0000000..12ef5d9
--- /dev/null
+++ b/splorm/src/state.rs
@@ -0,0 +1,2 @@
+#[derive(Clone, Debug)]
+pub struct SplormState {}