Commit Diff


commit - /dev/null
commit + 0918440e346e322a76c8041e8939e8e7e70de0d9
blob - /dev/null
blob + a79dad7e410e973d9cab54a49d4391bc0d1a66ae (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,2 @@
+/target
+/www.cgi
blob - /dev/null
blob + f5610c5363aaf340dd7b51649d04782260bf5d3b (mode 644)
--- /dev/null
+++ Cargo.lock
@@ -0,0 +1,299 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
+
+[[package]]
+name = "bumpalo"
+version = "3.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa"
+
+[[package]]
+name = "cc"
+version = "1.0.90"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-targets",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+
+[[package]]
+name = "either"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.153"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
+
+[[package]]
+name = "log"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+
+[[package]]
+name = "num-traits"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11a6ae1e52eb25aab8f3fb9fca13be982a373b8f1157ca14b897a825ba4a2d35"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
+
+[[package]]
+name = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
+
+[[package]]
+name = "www-cgi"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "itertools",
+]
blob - /dev/null
blob + edf52e3f33ca9a1b86120d49ec312c3d8221feb4 (mode 644)
--- /dev/null
+++ Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "www-cgi"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+chrono = "0.4.37"
+itertools = "0.12.1"
blob - /dev/null
blob + 66a34507a8648c546ed689da668190936ab6ce2a (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,24 @@
+
+SRC != find src -name '*.rs'
+
+RUSTFLAGS =	-C relocation-model=static	\
+	  	-L native=/usr/lib		\
+		-l static=c			\
+		-l static=c++abi		\
+		-l static=pthread		\
+		-C link-arg=-static		\
+		-C panic=abort
+
+all: www.cgi
+
+clean:
+	cargo clean
+	rm -f www.cgi
+
+install: www.cgi
+	scp www.cgi server:/var/www/bin/
+	openrsync --rsync-path=/usr/bin/openrsync -tr --delete --exclude fix cats server:/var/www/htdocs/
+
+www.cgi: ${SRC} src/style.css
+	cargo rustc --release -- ${RUSTFLAGS}
+	mv -f target/release/www-cgi www.cgi
blob - /dev/null
blob + 7bedcdc0bf2a3c12294b2082d086c7a6697774e3 (mode 644)
Binary files /dev/null and cats/1.jpg differ
blob - /dev/null
blob + a79e1b8932a75749300d618aa171a0b6c7625a7d (mode 644)
Binary files /dev/null and cats/10.jpg differ
blob - /dev/null
blob + b43f918169ce12d67f1cdc0d44a1b3689f7fdedb (mode 644)
Binary files /dev/null and cats/11.jpg differ
blob - /dev/null
blob + 4998b5f31b54224588e6ee9d5974579c40a40d36 (mode 644)
Binary files /dev/null and cats/12.jpg differ
blob - /dev/null
blob + cf7ba488e8766077d7ef10110ca61ee025958c55 (mode 644)
Binary files /dev/null and cats/13.jpg differ
blob - /dev/null
blob + e4953de7b4091c16e7894961daf2bce4b0f555e0 (mode 644)
Binary files /dev/null and cats/14.jpg differ
blob - /dev/null
blob + 9574078edfb79338cc2caba1ed8da6ddf40b6924 (mode 644)
Binary files /dev/null and cats/15.jpg differ
blob - /dev/null
blob + 323579dd34f4be587b146451ea83b6dc6795e17c (mode 644)
Binary files /dev/null and cats/16.jpg differ
blob - /dev/null
blob + 44d2bbaa1d866c126595a702ffd1e9c14fc60266 (mode 644)
Binary files /dev/null and cats/17.jpg differ
blob - /dev/null
blob + 2bea3e4213191917739baa4a2d32cfb91aa77693 (mode 644)
Binary files /dev/null and cats/18.jpg differ
blob - /dev/null
blob + dae59c6e7706d225d073c4cf7b5a0cf440a79c90 (mode 644)
Binary files /dev/null and cats/19.jpg differ
blob - /dev/null
blob + 8375a0240277de48131bef446d7959b128d1fbf1 (mode 644)
Binary files /dev/null and cats/2.jpg differ
blob - /dev/null
blob + b96e4ba3518b475dfb8a79259ea7ae9b6cbb46c1 (mode 644)
Binary files /dev/null and cats/20.jpg differ
blob - /dev/null
blob + 376527b16c4236a21db689c093f99ccd69735e8f (mode 644)
Binary files /dev/null and cats/21.jpg differ
blob - /dev/null
blob + bb269d0a6b70ff58bbca3725fb11af3b75acc093 (mode 644)
Binary files /dev/null and cats/22.jpg differ
blob - /dev/null
blob + 50930ac894fcc6eaaa24e0d53cc38a7765df4128 (mode 644)
Binary files /dev/null and cats/23.jpg differ
blob - /dev/null
blob + 44720e2cea2e70ca2b5ed8fd8edafc38b29eb8d8 (mode 644)
Binary files /dev/null and cats/24.jpg differ
blob - /dev/null
blob + 5a76b8cffec005fe05721a6e8085e223814c4c41 (mode 644)
Binary files /dev/null and cats/25.jpg differ
blob - /dev/null
blob + 9410cad4d5034dc88b56eddff546cf8d443de7e0 (mode 644)
Binary files /dev/null and cats/26.jpg differ
blob - /dev/null
blob + bdd3117974a1753c25328db71767e62da730af6e (mode 644)
Binary files /dev/null and cats/27.jpg differ
blob - /dev/null
blob + f870d288d92e6d96ecab53902d0d40654a31ad1a (mode 644)
Binary files /dev/null and cats/28.jpg differ
blob - /dev/null
blob + 220d0e4135a019f04d308cdaba8d63429f9643cb (mode 644)
Binary files /dev/null and cats/3.jpg differ
blob - /dev/null
blob + eb672b6b24d6368995be0cbf5f17411867799ba7 (mode 644)
Binary files /dev/null and cats/4.jpg differ
blob - /dev/null
blob + c4882f6eef556b98b614c1b80b93ea797c80989d (mode 644)
Binary files /dev/null and cats/5.jpg differ
blob - /dev/null
blob + 82c9d90af5cf146e0ace8fc3a1d84f4baa0c9d20 (mode 644)
Binary files /dev/null and cats/6.jpg differ
blob - /dev/null
blob + f604acadf017b7a32322742608336ad5b13201d9 (mode 644)
Binary files /dev/null and cats/7.jpg differ
blob - /dev/null
blob + d61e8544fe298c2f82aab8de4c4bbb81b1d13ada (mode 644)
Binary files /dev/null and cats/8.jpg differ
blob - /dev/null
blob + 08bfbfab54d01d524ee06f8c03fb43c5720752ab (mode 644)
Binary files /dev/null and cats/9.jpg differ
blob - /dev/null
blob + 9553ae49e957dc45fe0ab1dfe070911fd07c60e1 (mode 755)
--- /dev/null
+++ cats/fix
@@ -0,0 +1,27 @@
+#!/bin/sh
+
+check() {
+	command -v "$1" > /dev/null || { echo "Please install '$1'" >&2; exit 1; }
+}
+
+check 'convert'
+check 'jpegoptim'
+check 'exiftran'
+
+for png in *.png; do
+	[ "$png" = "*.png" ] && break
+	convert "$png" "$(basename "$png" .png).jpg"
+	rm -f "$png"
+done
+
+
+i=$(find . -name '*.jpg' -maxdepth 1 | sed -En 's#^(\./)?([0-9]+)\.jpg$#\2#p' | sort -n | tail -n1)
+i=$((i + 1))
+
+for f in *.jpg; do
+	echo "$f" | grep -qE '^[0-9]+\.jpg$' && continue
+	jpegoptim -sS1024 -T 25 "$f"
+	exiftran -ai "$f"
+	mv "$f" "$i.jpg"
+	i=$((i + 1))
+done
blob - /dev/null
blob + 631940df13cd97176e7206e4544b1c19dae1eae5 (mode 644)
--- /dev/null
+++ src/html.rs
@@ -0,0 +1,184 @@
+use std::fmt::{self, Display, Formatter};
+
+#[derive(Clone)]
+pub struct Tag {
+    pub name: &'static str,
+    pub args: Vec<(&'static str, String)>,
+    pub children: Vec<Element>,
+    pub self_closing: bool,
+}
+
+#[derive(Clone)]
+pub enum Element {
+    Tag(Tag),
+    String(String),
+}
+
+#[derive(Clone, Copy)]
+pub struct Indent(u32);
+
+impl Element {
+    pub fn render(&self, f: &mut Formatter<'_>, ind: Indent) -> fmt::Result {
+	match self {
+	    Self::Tag(Tag { name, args, children, self_closing }) => {
+		write!(f, "{ind}<{name}")?;
+		for (name, value) in args {
+		    write!(f, " {name}={value:?}")?;
+		}
+		write!(f, ">")?;
+		match &children[..] {
+		    [] if *self_closing => writeln!(f),
+		    [] => writeln!(f, "</{name}>"),
+		    [Element::String(s)] => writeln!(f, "{s}</{name}>"),
+		    _ => {
+			writeln!(f)?;
+
+			for child in children {
+			    child.render(f, ind.next())?;
+			}
+			
+			writeln!(f, "{ind}</{name}>")?;
+			Ok(())
+		    }
+		}
+	    },
+	    Self::String(s) => writeln!(f, "{ind}{s}"),
+	}
+    }
+}
+
+impl From<&str> for Element {
+    fn from(value: &str) -> Self {
+	Self::String(value.into())
+    }
+}
+
+impl From<String> for Element {
+    fn from(value: String) -> Self {
+	Self::String(value)
+    }
+}
+
+impl Display for Element {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+	self.render(f, Indent(0))
+    }
+}
+
+impl Display for Indent {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+	for _ in 0..self.0 {
+	    write!(f, "\t")?;
+	}
+	Ok(())
+    }
+}
+
+impl Indent {
+    fn next(self) -> Self {
+	Self(self.0 + 1)
+    }
+}
+
+#[macro_export]
+macro_rules! html {
+    ($name:ident $([ $($an:tt = $av:expr),* $(,)? ])? { $($tk:tt)* }) => {
+	{
+	    #[allow(unused_mut)]
+	    let mut tag = crate::html::Tag {
+		name: stringify!($name),
+		args: vec! [
+		    $(
+			$(
+			    (html!(@ $an), $av.to_string())
+			),*
+		    )?
+		],
+		children: Vec::new(),
+		self_closing: false,
+	    };
+	    
+	    html! {
+		! tag $($tk)*
+	    }
+
+	    crate::html::Element::Tag(tag)
+	}
+    };
+    (! $parent:ident $name:ident $([$($args:tt)*])? { $($tk:tt)* } $($rest:tt)*) => {
+	$parent.children.push(html! { $name $([$($args)*])? { $($tk)* } });
+
+	html! {
+	    ! $parent $($rest)*
+	}
+    };
+    (! $parent:ident $name:ident $([$($an:ident = $av:expr),*])? ; $($rest:tt)*) => {
+	$parent.children.push(
+	    crate::html::Element::Tag(crate::html::Tag {
+		name: stringify!($name),
+		args: vec! [
+		    $(
+			$(
+			    (html! { @$an}, $av.to_string())
+			),*
+		    )?
+		],
+		children: Vec::new(),
+		self_closing: true,
+	    })
+	);
+
+	html! {
+	    ! $parent $($rest)*
+	}
+    };
+    (! $parent:ident $name:ident = $val:expr ; $($rest:tt)*) => {
+	$parent.children.push(crate::html::Element::Tag(crate::html::Tag {
+	    name: stringify!($name),
+	    args: Vec::new(),
+	    children: vec![
+		crate::html::Element::String($val.into())
+	    ],
+	    self_closing: false,
+	}));
+
+	html! {
+	    ! $parent $($rest)*
+	}
+    };
+    (! $parent:ident $s:literal ; $($rest:tt)*) => {
+	$parent.children.push(crate::html::Element::String(format!($s)));
+
+	html! {
+	    ! $parent $($rest)*
+	}
+    };
+    (! $parent:ident { $e:expr } $($rest:tt)*) => {
+	$parent.children.push($e.into());
+
+	html! {
+	    ! $parent $($rest)*
+	}
+    };
+    (! $parent:ident ? { $e:expr } $($rest:tt)*) => {
+	if let Some(e) = $e {
+	    $parent.children.push(e.into());
+	}
+
+	html! {
+	    ! $parent $($rest)*
+	}
+    };
+    (! $parent:ident [ $e:expr ] $($rest:tt)*) => {
+	for e in $e {
+	    $parent.children.push(e.into());
+	}
+
+	html! {
+	    ! $parent $($rest)*
+	}
+    };
+    (! $parent:ident) => {};
+    (@ $i:ident) => { stringify!($i) };
+    (@ $s:literal) => { $s };
+}
blob - /dev/null
blob + f160afdc57b27872393d3c3419d1bd1ce8f4c135 (mode 644)
--- /dev/null
+++ src/main.rs
@@ -0,0 +1,88 @@
+use crate::{html::Element, site::route};
+
+mod site;
+mod html;
+
+type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
+
+pub struct Page {
+    title: String,
+    html: Element,
+}
+
+pub struct Request {
+    pub path: String,
+    pub query: String,
+}
+
+fn parse() -> Result<Request> {
+    let (path, query) = if std::env::args().count() >= 2 {
+	let path = std::env::args().nth(1).unwrap();
+	let query = std::env::args().nth(2).unwrap_or_else(String::new);
+	(path, query)
+    } else {
+	let path = std::env::var("DOCUMENT_URI")?;
+	let query = std::env::var("QUERY_STRING")?;
+	(path, query)
+    };
+
+    let path = path
+	.strip_prefix("/test")
+	.expect("failed to strip prefix")
+	.to_string();
+    
+    Ok(Request {
+	path,
+	query,
+    })
+}
+
+fn main() -> Result<()> {
+    let req = parse()?;
+    let (page, ok) = match route(&req) {
+	Ok(page) => (page, true),
+	Err(e) => {
+	    let page = Page {
+		title: "Internal Server Error".into(),
+		html: html! {
+		    main {
+			h1 = "Internal Server Error";
+			p {
+			    "Sorry, an error occured.";
+			    "Error: {e}";
+			}
+		    }
+		}
+	    };
+	    (page, false)
+	},
+    };
+
+    let top = html! {
+	html {
+	    head {
+		title = format!("Test - {}", page.title);
+		style {
+		    [
+			include_str!("style.css")
+			    .lines()
+		    ]
+		}
+	    }
+	    body [style="background-color: #222; color: #ccc; font-size: 18px"] {
+		{site::menu()}
+		{page.html}
+	    }
+	}
+    };
+
+    if !ok {
+	    println!("Status: 500 Internal Server Error");
+    }
+    println!("Content-Type: text/html");
+    println!();
+    println!("<!DOCTYPE html>");
+    println!("{top}");
+
+    Ok(())
+}
blob - /dev/null
blob + 2ecb2164438d24a4fe5c5ca38fe1496a88a3339e (mode 644)
--- /dev/null
+++ src/site/cats.rs
@@ -0,0 +1,70 @@
+use crate::{site::Page, html, Result, Request};
+
+
+pub fn index(req: &Request) -> Result<Page> {
+    let mut max = 0u32;
+    let q = &req.query;
+
+    for ent in std::fs::read_dir("/htdocs/cats")? {
+	let ent = ent?;
+	let name = ent.file_name();
+	let name = name.to_string_lossy();
+	let Some(name) = name.strip_suffix(".jpg") else { continue };
+	let Ok(id) = name.parse() else { continue };
+	if id > max {
+	    max = id;
+	}
+    }
+
+    if max == 0 {
+	return Ok(Page {
+	    title: "Error".into(),
+	    html: html! {
+		main {
+		    h1 = "No Cat Pictures";
+		    p = "Sorry, I don't have any cat pictures at the moment.";
+		}
+	    }
+	});
+    }
+
+    let id = q.parse().unwrap_or(1u32);
+
+    let maybe_link = |cond, label: &str, target: String| {
+	if cond {
+	    html! {
+		a [href=target] {
+		    {label}
+		}
+	    }
+	} else {
+	    label.into()
+	}
+    };
+
+    let html = html! {
+	main [style="width: 1280px; margin-left: auto; margin-right: auto;"] {
+	    h1 = "Cats";
+	    div [style="border: 1px solid #fff; margin-left: auto; margin-right: auto; display: inline-block; font-size: 20px; height=80vh"] {
+		a [href=format!("/cats/{id}.jpg")] {
+		    img [src=format!("/cats/{id}.jpg"), alt="cat picture", style="width: auto; max-width: 100%; max-height: 80vh"];
+		}
+		br;
+		div [align="center"] {
+		    {maybe_link(id > 1, "First", ".".into())}
+		    " | ";
+		    {maybe_link(id > 1, "Prev", format!(".?{}", id - 1))}
+		    " | ";
+		    {maybe_link(id < max, "Next", format!(".?{}", id + 1))}
+		    " | ";
+		    {maybe_link(id < max, "Last", format!(".?{}", max))}
+		}
+	    }
+	}
+    };
+
+    Ok(Page {
+	title: "Cats".into(),
+	html,
+    })
+}
blob - /dev/null
blob + 10fcd13692ebced9ea3e5396f2b6b4ad3eea3d98 (mode 644)
--- /dev/null
+++ src/site/index.rs
@@ -0,0 +1,14 @@
+use crate::{Result, site::Page, html, Request};
+
+pub fn index(_req: &Request) -> Result<Page> {
+    let html = html! {
+	main {
+	    h1 = "Index";
+	    p = "This is a simple CGI test website.";
+	}
+    };
+    Ok(Page {
+	title: "Index".into(),
+	html,
+    })
+}
blob - /dev/null
blob + e0bab8843979f0282e188af477668371bf19f95f (mode 644)
--- /dev/null
+++ src/site/mod.rs
@@ -0,0 +1,99 @@
+use itertools::Itertools;
+use crate::{Result, Page, Request, html, html::Element};
+
+pub type Handler = fn(&Request) -> Result<Page>;
+
+struct Route {
+    prefix: &'static str,
+    handler: Handler,
+    label: Option<(&'static str, u32)>,
+}
+
+mod index;
+mod cats;
+mod time;
+
+macro_rules! routes {
+    [$($prefix:literal => $handler:path $([$($label:tt)+])?),* $(,)?] => {
+	&[
+	    $(
+		routes!(! $prefix => $handler $([$($label)+])?)
+	    ),*
+	]
+    };
+    (! $prefix:literal => $handler:path [$label:literal : $weight:expr]) => {
+	Route {
+	    prefix: $prefix,
+	    handler: $handler,
+	    label: Some(($label, $weight)),
+	}
+    };
+    (! $prefix:literal => $handler:path) => {
+	Route {
+	    prefix: $prefix,
+	    handler: $handler,
+	    label: None,
+	}
+    };
+}
+
+fn not_found(req: &Request) -> Result<Page> {
+    let path = &req.path;
+    Ok(Page {
+	title: "Not Found".into(),
+	html: html! {
+	    main {
+		h1 = "404 - Not Found";
+		p { "Invalid path: {path:?}"; }
+	    }
+	},
+    })
+}
+
+const ROUTES: &[Route] = routes![
+    "/time/" => time::index ["Time" : 2],
+    "/cats/" => cats::index ["Cats" : 1],
+    "/" => index::index ["Index" : 0],
+    "/*" => not_found,
+];
+
+fn matches(path: &str, pattern: &str) -> bool {
+    if pattern.ends_with("/*") {
+	path.ends_with(&pattern[..pattern.len() - 2])
+    } else {
+	path == pattern
+    }
+}
+
+fn find_route<'a>(req: &'a Request) -> &'a Route {
+    ROUTES
+        .iter()
+        .find(|r| matches(&req.path, r.prefix))
+        .expect("failed to find route")
+}
+
+pub fn route(req: &Request) -> Result<Page> {
+    (find_route(req).handler)(req)
+}
+
+#[allow(unstable_name_collisions)]
+pub fn menu() -> Element {
+    html! {
+	nav {
+	    [
+		ROUTES
+		    .iter()
+		    .filter_map(|r| r.label.as_ref().map(|l| (l, r.prefix)))
+		    .map(|((l, w), p)| (l, w, p))
+		    .sorted_by(|(_, w1, _), (_, w2, _)| w1.cmp(w2))
+		    .map(|(l, _, p)| {
+			let p = p.strip_suffix('*').unwrap_or(p);
+			html! {
+			    a [href=format!("/test{p}")] { {*l} }
+			}
+		    })
+		    .intersperse(" | ".into())
+	    ]
+	}
+    }
+}
blob - /dev/null
blob + 3f0d74e31198ba166fb4cbb438549557642ba6ce (mode 644)
--- /dev/null
+++ src/site/time.rs
@@ -0,0 +1,24 @@
+use chrono::Local;
+use crate::{Request, Result, Page, html};
+
+pub fn index(_req: &Request) -> Result<Page> {
+    let time = Local::now().to_rfc2822();
+    let script = r#"
+async function init() {
+    console.log("Waiting...");
+    await new Promise(res => setTimeout(res, 3000));
+    location.reload();
+}
+window.onload = init;
+"#;
+    Ok(Page {
+	title: "Current Time".into(),
+	html: html! {
+	    main {
+		script { "{script}"; }
+		h1 = "The Current System Time";
+		p { "{time}"; }
+	    }
+	},
+    })
+}
blob - /dev/null
blob + d94c14ec7f2b62b5fe3b73ee78cf4d02798472af (mode 644)
--- /dev/null
+++ src/style.css
@@ -0,0 +1,3 @@
+a {
+    color: dodgerblue;
+}