Commit Diff


commit - 7aeb283ccfe023dbb0450aebc274f477136373a7
commit + 4cd4f508c779119905ab9b9d2ac22c9b0fedab51
blob - f5610c5363aaf340dd7b51649d04782260bf5d3b
blob + c9a2bda3ca9874ab546594f77d3cd8648a9368d7
--- Cargo.lock
+++ Cargo.lock
@@ -3,6 +3,15 @@
 version = 3
 
 [[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "android-tzdata"
 version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -18,6 +27,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "anyhow"
+version = "1.0.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247"
+
+[[package]]
 name = "autocfg"
 version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -68,6 +83,17 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
 
 [[package]]
+name = "getrandom"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
 name = "iana-time-zone"
 version = "0.1.60"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -109,6 +135,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
 name = "libc"
 version = "0.2.153"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -121,6 +153,12 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
 
 [[package]]
+name = "memchr"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
+
+[[package]]
 name = "num-traits"
 version = "0.2.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -136,6 +174,21 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
 
 [[package]]
+name = "pledge"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252599417b7d9a43b7fdc63dd790b0848666a8910b2ebe1a25118309c3c981e5"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
+[[package]]
 name = "proc-macro2"
 version = "1.0.79"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -154,6 +207,65 @@ dependencies = [
 ]
 
 [[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
+
+[[package]]
 name = "syn"
 version = "2.0.57"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -171,6 +283,21 @@ source = "registry+https://github.com/rust-lang/crates
 checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
 
 [[package]]
+name = "unveil"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e7fa867d559102001ec694165ed17d5f82e95213060a65f9c8b6280084bbfec"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
 name = "wasm-bindgen"
 version = "0.2.92"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -294,6 +421,12 @@ checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16
 name = "www-cgi"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "chrono",
  "itertools",
+ "lazy_static",
+ "pledge",
+ "rand",
+ "regex",
+ "unveil",
 ]
blob - edf52e3f33ca9a1b86120d49ec312c3d8221feb4
blob + b9b934e4170c8bf0a4cb39c1f5b644171ad907e3
--- Cargo.toml
+++ Cargo.toml
@@ -6,5 +6,11 @@ edition = "2021"
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [dependencies]
+anyhow = "1.0.81"
 chrono = "0.4.37"
 itertools = "0.12.1"
+lazy_static = "1.4.0"
+pledge = "0.4.2"
+rand = "0.8.5"
+regex = "1.10.4"
+unveil = "0.3.2"
blob - 66a34507a8648c546ed689da668190936ab6ce2a
blob + 8becc8c6c5bbe033a3f1b955002b764e8ba23592
--- Makefile
+++ Makefile
@@ -16,8 +16,8 @@ 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/
+	ssh server "cd /var/www/bin && cat - > www.cgi.new && chmod 755 www.cgi.new && mv www.cgi.new www.cgi" < www.cgi
+	openrsync --rsync-path=/usr/bin/openrsync -tr --delete --exclude fix www-cgi server:/var/www/htdocs/
 
 www.cgi: ${SRC} src/style.css
 	cargo rustc --release -- ${RUSTFLAGS}
blob - 7bedcdc0bf2a3c12294b2082d086c7a6697774e3 (mode 644)
blob + /dev/null
Binary files cats/1.jpg and /dev/null differ
blob - a79e1b8932a75749300d618aa171a0b6c7625a7d (mode 644)
blob + /dev/null
Binary files cats/10.jpg and /dev/null differ
blob - b43f918169ce12d67f1cdc0d44a1b3689f7fdedb (mode 644)
blob + /dev/null
Binary files cats/11.jpg and /dev/null differ
blob - 4998b5f31b54224588e6ee9d5974579c40a40d36 (mode 644)
blob + /dev/null
Binary files cats/12.jpg and /dev/null differ
blob - cf7ba488e8766077d7ef10110ca61ee025958c55 (mode 644)
blob + /dev/null
Binary files cats/13.jpg and /dev/null differ
blob - e4953de7b4091c16e7894961daf2bce4b0f555e0 (mode 644)
blob + /dev/null
Binary files cats/14.jpg and /dev/null differ
blob - 9574078edfb79338cc2caba1ed8da6ddf40b6924 (mode 644)
blob + /dev/null
Binary files cats/15.jpg and /dev/null differ
blob - 323579dd34f4be587b146451ea83b6dc6795e17c (mode 644)
blob + /dev/null
Binary files cats/16.jpg and /dev/null differ
blob - 44d2bbaa1d866c126595a702ffd1e9c14fc60266 (mode 644)
blob + /dev/null
Binary files cats/17.jpg and /dev/null differ
blob - 2bea3e4213191917739baa4a2d32cfb91aa77693 (mode 644)
blob + /dev/null
Binary files cats/18.jpg and /dev/null differ
blob - dae59c6e7706d225d073c4cf7b5a0cf440a79c90 (mode 644)
blob + /dev/null
Binary files cats/19.jpg and /dev/null differ
blob - 8375a0240277de48131bef446d7959b128d1fbf1 (mode 644)
blob + /dev/null
Binary files cats/2.jpg and /dev/null differ
blob - b96e4ba3518b475dfb8a79259ea7ae9b6cbb46c1 (mode 644)
blob + /dev/null
Binary files cats/20.jpg and /dev/null differ
blob - 376527b16c4236a21db689c093f99ccd69735e8f (mode 644)
blob + /dev/null
Binary files cats/21.jpg and /dev/null differ
blob - bb269d0a6b70ff58bbca3725fb11af3b75acc093 (mode 644)
blob + /dev/null
Binary files cats/22.jpg and /dev/null differ
blob - 50930ac894fcc6eaaa24e0d53cc38a7765df4128 (mode 644)
blob + /dev/null
Binary files cats/23.jpg and /dev/null differ
blob - 44720e2cea2e70ca2b5ed8fd8edafc38b29eb8d8 (mode 644)
blob + /dev/null
Binary files cats/24.jpg and /dev/null differ
blob - 5a76b8cffec005fe05721a6e8085e223814c4c41 (mode 644)
blob + /dev/null
Binary files cats/25.jpg and /dev/null differ
blob - 9410cad4d5034dc88b56eddff546cf8d443de7e0 (mode 644)
blob + /dev/null
Binary files cats/26.jpg and /dev/null differ
blob - bdd3117974a1753c25328db71767e62da730af6e (mode 644)
blob + /dev/null
Binary files cats/27.jpg and /dev/null differ
blob - f870d288d92e6d96ecab53902d0d40654a31ad1a (mode 644)
blob + /dev/null
Binary files cats/28.jpg and /dev/null differ
blob - 220d0e4135a019f04d308cdaba8d63429f9643cb (mode 644)
blob + /dev/null
Binary files cats/3.jpg and /dev/null differ
blob - eb672b6b24d6368995be0cbf5f17411867799ba7 (mode 644)
blob + /dev/null
Binary files cats/4.jpg and /dev/null differ
blob - c4882f6eef556b98b614c1b80b93ea797c80989d (mode 644)
blob + /dev/null
Binary files cats/5.jpg and /dev/null differ
blob - 82c9d90af5cf146e0ace8fc3a1d84f4baa0c9d20 (mode 644)
blob + /dev/null
Binary files cats/6.jpg and /dev/null differ
blob - f604acadf017b7a32322742608336ad5b13201d9 (mode 644)
blob + /dev/null
Binary files cats/7.jpg and /dev/null differ
blob - d61e8544fe298c2f82aab8de4c4bbb81b1d13ada (mode 644)
blob + /dev/null
Binary files cats/8.jpg and /dev/null differ
blob - 08bfbfab54d01d524ee06f8c03fb43c5720752ab (mode 644)
blob + /dev/null
Binary files cats/9.jpg and /dev/null differ
blob - 9553ae49e957dc45fe0ab1dfe070911fd07c60e1 (mode 755)
blob + /dev/null
--- cats/fix
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/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 - 631940df13cd97176e7206e4544b1c19dae1eae5
blob + 2d337971565404597d6b3f9384a996161605ef14
--- src/html.rs
+++ src/html.rs
@@ -1,6 +1,6 @@
 use std::fmt::{self, Display, Formatter};
 
-#[derive(Clone)]
+#[derive(Debug, Clone)]
 pub struct Tag {
     pub name: &'static str,
     pub args: Vec<(&'static str, String)>,
@@ -8,7 +8,7 @@ pub struct Tag {
     pub self_closing: bool,
 }
 
-#[derive(Clone)]
+#[derive(Debug, Clone)]
 pub enum Element {
     Tag(Tag),
     String(String),
@@ -17,6 +17,17 @@ pub enum Element {
 #[derive(Clone, Copy)]
 pub struct Indent(u32);
 
+impl Tag {
+    pub fn new(name: &'static str) -> Self {
+	Self {
+	    name,
+	    args: Vec::new(),
+	    children: Vec::new(),
+	    self_closing: false,
+	}
+    }
+}
+
 impl Element {
     pub fn render(&self, f: &mut Formatter<'_>, ind: Indent) -> fmt::Result {
 	match self {
@@ -42,6 +53,7 @@ impl Element {
 		    }
 		}
 	    },
+	    Self::String(s) if s.is_empty() => Ok(()),
 	    Self::String(s) => writeln!(f, "{ind}{s}"),
 	}
     }
blob - f160afdc57b27872393d3c3419d1bd1ce8f4c135
blob + cb97d409ea077b2cfb38f78544d4b3afc41f3298
--- src/main.rs
+++ src/main.rs
@@ -1,9 +1,14 @@
+pub use anyhow::Result;
+use pledge::pledge;
+use unveil::unveil;
 use crate::{html::Element, site::route};
 
+const CONFIG_BASE_DIR: &str = "/htdocs/www-cgi";
+
 mod site;
 mod html;
+mod markdown;
 
-type Result<T, E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
 
 pub struct Page {
     title: String,
@@ -15,6 +20,15 @@ pub struct Request {
     pub query: String,
 }
 
+impl Element {
+    fn as_page(self, title: impl Into<String>) -> Page {
+	Page {
+	    title: title.into(),
+	    html: self,
+	}
+    }
+}
+
 fn parse() -> Result<Request> {
     let (path, query) = if std::env::args().count() >= 2 {
 	let path = std::env::args().nth(1).unwrap();
@@ -23,6 +37,11 @@ fn parse() -> Result<Request> {
     } else {
 	let path = std::env::var("DOCUMENT_URI")?;
 	let query = std::env::var("QUERY_STRING")?;
+
+	unveil(CONFIG_BASE_DIR, "r")?;
+	unveil("", "")?;
+	pledge("stdio rpath", None)?;
+
 	(path, query)
     };
 
@@ -59,15 +78,17 @@ fn main() -> Result<()> {
     };
 
     let top = html! {
-	html {
+	html [lang="en"] {
 	    head {
-		title = format!("Test - {}", page.title);
+		title = format!("{} - Test", page.title);
+		meta [charset="utf-8"];
 		style {
 		    [
 			include_str!("style.css")
 			    .lines()
 		    ]
 		}
+		//meta ["http-equiv"="Content-Security-Policy", content="default"] {}
 	    }
 	    body [style="background-color: #222; color: #ccc; font-size: 18px"] {
 		{site::menu()}
@@ -80,6 +101,7 @@ fn main() -> Result<()> {
 	    println!("Status: 500 Internal Server Error");
     }
     println!("Content-Type: text/html");
+    println!("X-Frame-Options: ALLOW-FROM *");
     println!();
     println!("<!DOCTYPE html>");
     println!("{top}");
blob - 2ecb2164438d24a4fe5c5ca38fe1496a88a3339e
blob + d042dbc2f78261480b0cb1c5ab414f548bc93487
--- src/site/cats.rs
+++ src/site/cats.rs
@@ -1,11 +1,11 @@
-use crate::{site::Page, html, Result, Request};
+use crate::{site::Page, html, Result, Request, CONFIG_BASE_DIR};
+use rand::Rng;
 
-
 pub fn index(req: &Request) -> Result<Page> {
     let mut max = 0u32;
     let q = &req.query;
 
-    for ent in std::fs::read_dir("/htdocs/cats")? {
+    for ent in std::fs::read_dir(format!("{CONFIG_BASE_DIR}/cats"))? {
 	let ent = ent?;
 	let name = ent.file_name();
 	let name = name.to_string_lossy();
@@ -42,10 +42,13 @@ pub fn index(req: &Request) -> Result<Page> {
 	}
     };
 
+    let r = rand::thread_rng()
+        .gen_range(1..=max);
+
     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"] {
+	main [style="width: 1280px; margin-left: auto; margin-right: auto; width: fit-content"] {
+	    h1 [align="center"] { "Cats"; }
+	    div [style="border: 1px solid #fff; margin-left: auto; margin-right: auto; 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"];
 		}
@@ -55,6 +58,8 @@ pub fn index(req: &Request) -> Result<Page> {
 		    " | ";
 		    {maybe_link(id > 1, "Prev", format!(".?{}", id - 1))}
 		    " | ";
+		    {maybe_link(true, "Random", format!(".?{}", r))}
+		    " | ";
 		    {maybe_link(id < max, "Next", format!(".?{}", id + 1))}
 		    " | ";
 		    {maybe_link(id < max, "Last", format!(".?{}", max))}
blob - e0bab8843979f0282e188af477668371bf19f95f
blob + 0c84777685acf7ca9bf0482222b0246dd97ce5f0
--- src/site/mod.rs
+++ src/site/mod.rs
@@ -12,6 +12,7 @@ struct Route {
 mod index;
 mod cats;
 mod time;
+mod posts;
 
 macro_rules! routes {
     [$($prefix:literal => $handler:path $([$($label:tt)+])?),* $(,)?] => {
@@ -53,13 +54,14 @@ fn not_found(req: &Request) -> Result<Page> {
 const ROUTES: &[Route] = routes![
     "/time/" => time::index ["Time" : 2],
     "/cats/" => cats::index ["Cats" : 1],
+    "/posts/*" => posts::posts ["Posts" : 3],
     "/" => index::index ["Index" : 0],
     "/*" => not_found,
 ];
 
 fn matches(path: &str, pattern: &str) -> bool {
     if pattern.ends_with("/*") {
-	path.ends_with(&pattern[..pattern.len() - 2])
+	path.starts_with(&pattern[..pattern.len() - 1])
     } else {
 	path == pattern
     }
blob - 3f0d74e31198ba166fb4cbb438549557642ba6ce
blob + 62feb4253c0a8f7b27fb49558b2097a6e6cd7e1e
--- src/site/time.rs
+++ src/site/time.rs
@@ -3,19 +3,13 @@ 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}"; }
+		head {
+		    meta ["http-equiv"="refresh", content="3"] {}
+		}
 		h1 = "The Current System Time";
 		p { "{time}"; }
 	    }
blob - /dev/null
blob + 5098b96832aeedc5de31179c607cb50dea1646a4 (mode 644)
--- /dev/null
+++ src/site/posts.rs
@@ -0,0 +1,96 @@
+use anyhow::Result;
+use crate::{Request, Page, html, CONFIG_BASE_DIR};
+
+pub fn posts(req: &Request) -> Result<Page> {
+    let path = req
+	.path
+	.strip_prefix("/posts")
+	.unwrap()
+	.trim_end_matches('/');
+
+
+    if path.is_empty() {
+	index()
+    } else {
+	render(path)
+    }
+}
+
+
+fn index() -> Result<Page> {
+    let mut entries = Vec::new();
+    for ent in std::fs::read_dir(format!("{CONFIG_BASE_DIR}/posts"))? {
+	let ent = ent?;
+	let name = ent.file_name();
+	let name = name.to_string_lossy();
+	if name.starts_with('.') {
+	    continue;
+	}
+	let Some(name) = name.strip_suffix(".md") else { continue };
+	let meta = std::fs::read_to_string(ent.path())
+	    .map(|meta| crate::markdown::metadata(&meta))
+	    .unwrap_or_default();
+	entries.push((name.to_string(), meta));
+    }
+    
+    Ok(Page {
+	title: format!("Posts"),
+	html: html! {
+	    main {
+		h1 = "Posts";
+		ul {
+		    [
+			entries
+			    .into_iter()
+			    .map(|(name, meta)| {
+				let title = meta
+				    .title
+				    .as_deref()
+				    .unwrap_or(&name);
+				html! {
+				    li {
+					a [href=format!("/test/posts/{name}/")] { "{title}"; }
+				    }
+				}
+			    })
+		    ]
+		}
+	    }
+	},
+    })
+}
+
+fn render(path: &str) -> Result<Page> {
+    let filepath = format!("{CONFIG_BASE_DIR}/posts/{path}.md");
+    let s = std::fs::read_to_string(filepath)?;
+    let md = crate::markdown::compile(&s)?;
+    let title = md.meta.title.as_deref().unwrap_or(path);
+    Ok(Page {
+	title: format!("{title} - Posts"),
+	html: html! {
+	    main {
+		[
+		    md
+			.meta
+			.title
+			.as_deref()
+			.map(|title| html! { h1 { {title} } })
+			.into_iter()
+		]
+		{md.body}
+		[
+		    md
+			.meta
+			.date
+			.map(|date| html! {
+			    p [id="markdown-date"] {
+				"Last updated on ";
+				{date.to_string() + "."}
+			    }
+			})
+			.into_iter()
+		]
+	    }
+	},
+    })
+}
blob - /dev/null
blob + 7ac3223d79784ddca07460b7e33f4abbefee2f91 (mode 644)
--- /dev/null
+++ src/markdown.rs
@@ -0,0 +1,334 @@
+use std::{iter::Peekable, str::Lines};
+
+use anyhow::Result;
+use chrono::NaiveDate;
+use lazy_static::lazy_static;
+use regex::Regex;
+use crate::html::{Element, Tag};
+
+#[derive(Default)]
+pub struct Metadata {
+    pub title: Option<String>,
+    pub date: Option<NaiveDate>,
+}
+
+pub struct Markdown {
+    pub meta: Metadata,
+    pub body: Element,
+}
+
+#[derive(PartialEq, Eq)]
+enum Mode {
+    Normal,
+    Paragraph,
+    Code,
+    List,
+    Todo,
+}
+
+struct Compiler {
+    stack: Vec<Tag>,
+    mode: Mode,
+    meta: Metadata,
+}
+
+fn escape_char(s: &mut String, ch: char) {
+    match ch {
+	'<' => s.push_str("&lt;"),
+	'>' => s.push_str("&gt;"),
+	'&' => s.push_str("&amp;"),
+	'"' => s.push_str("&quot;"),
+	'\'' => s.push_str("&apos;"),
+	_   => s.push(ch),
+    }
+}
+fn escape(text: &str) -> String {
+    let mut s = String::with_capacity(text.len());
+    for ch in text.chars() {
+	escape_char(&mut s, ch);
+    }
+    s
+}
+
+macro_rules! regex {
+    ($s:literal) => {
+	Regex::new($s)
+	    .expect(concat!("failed to compile regex: ", $s))
+    };
+}
+
+lazy_static! {
+    static ref REGEX_TODO_BEGIN: Regex = regex!(r"^-\s*\[( |x)\]\s+(.*)$");
+}
+
+impl Compiler {
+    fn new() -> Self {
+	let mut div = Tag::new("div");
+	div.args.push(("class", "markdown".into()));
+	Self {
+	    stack: vec![div],
+	    mode: Mode::Normal,
+	    meta: Metadata::default(),
+	}
+    }
+    fn last(&self) -> &Tag {
+	self.stack.last().unwrap()
+    }
+    fn last_mut(&mut self) -> &mut Tag {
+	self.stack.last_mut().unwrap()
+    }
+    fn pop(&mut self) {
+	let tag = self.stack.pop().unwrap();
+	self.last_mut().children.push(Element::Tag(tag));
+    }
+    fn seq(&mut self, tag: &'static str) {
+	let last = self.last().name;
+	if last == tag {
+	    self.pop();
+	} else {
+	    self.stack.push(Tag::new(tag));
+	}
+    }
+    fn compile_string(&mut self, text: &str) {
+	let mut iter = text.chars().peekable();
+	let num = self.stack.len();
+
+	while let Some(ch) = iter.next() {
+	    let is_code = self.last().name == "span";
+	    if ch == '*' && !is_code {
+		let ty = if iter.next_if_eq(&'*').is_some() {
+		    "b"
+		} else {
+		    "i"
+		};
+		self.seq(ty);
+	    } else if ch == '_' && !is_code {
+		self.seq("u");
+	    } else if ch == '~' && !is_code {
+		self.seq("s");
+	    } else if ch == '`' {
+		self.seq("span");
+	    } else if ch == '[' && !is_code {
+		let mut text = String::new();
+		while let Some(ch) = iter.next() {
+		    if ch == ']' {
+			break;
+		    }
+		    text.push(ch);
+		}
+		if iter.next_if_eq(&'(').is_none() {
+		    continue;
+		}
+
+		let mut link = String::new();
+		while let Some(ch) = iter.next() {
+		    if ch == ')' {
+			break;
+		    }
+		    link.push(ch);
+		}
+
+		let mut tag = Tag::new("a");
+		tag.args.push(("href", escape(&link)));
+		self.stack.push(tag);
+		self.compile_string(&text);
+		self.pop();
+	    } else {
+		let last = self.last_mut();
+		match last.children.last_mut() {
+		    Some(Element::String(s)) => escape_char(s, ch),
+		    _ => last.children.push(Element::String({
+			let mut s = String::new();
+			escape_char(&mut s, ch);
+			s
+		    })),
+		}
+	    }
+	}
+
+	if self.stack.len() < num {
+	    panic!("stack underflow");
+	} else if self.stack.len() > num {
+	    eprintln!("Warning: unclosed sequence");
+	    self.stack.resize_with(num, || unreachable!());
+	}
+    }
+    fn set_mode(&mut self, m: Mode) {
+	if self.mode != Mode::Normal {
+	    self.pop();
+	}
+	self.mode = m;
+    }
+    fn push(&mut self, e: Element) {
+	self.last_mut().children.push(e);
+    }
+    fn compile_line(&mut self, line: &str) {
+	if self.mode == Mode::Code {
+	    if line.starts_with("```") {
+		self.set_mode(Mode::Normal);
+	    } else {
+		let mut tag = Tag::new("span");
+		tag.children.push(Element::String(escape(line)));
+		self.push(Element::Tag(tag));
+		let mut br = Tag::new("br");
+		br.self_closing = true;
+		self.push(Element::Tag(br));
+	    }
+	    return;
+	}
+
+	let mut header = |prefix: &str, tag| {
+	    self.set_mode(Mode::Normal);
+	    self.stack.push(Tag::new(tag));
+	    let text = line
+		.trim_start_matches(prefix)
+		.trim();
+	    self.compile_string(text);
+	    self.pop();
+	};
+	
+	if line.starts_with("####") {
+	    header("####", "h4");
+	} else if line.starts_with("###") {
+	    header("###", "h3");
+	} else if line.starts_with("##") {
+	    header("##", "h2");
+	} else if line.starts_with("#") {
+	    header("#", "h1");
+	} else if line.starts_with("```") {
+	    self.set_mode(Mode::Code);
+	    let mut tag = Tag::new("div");
+	    tag.args.push(("class", "code".into()));
+	    self.stack.push(tag);
+	} else if let Some(cap) = REGEX_TODO_BEGIN.captures(line) {
+	    let rest = cap.get(2).unwrap().as_str();
+
+	    let status = match cap.get(1).unwrap().as_str() {
+		"x"	=> "done",
+		" "	=> "open",
+		s	=> panic!("invalid todo: {s}"),
+	    };
+
+	    if self.mode != Mode::Todo {
+		self.set_mode(Mode::Todo);
+		self.stack.push(Tag {
+		    name: "ul",
+		    args: vec![("class", "todo".into())],
+		    children: Vec::new(),
+		    self_closing: false,
+		});
+	    }
+
+	    self.stack.push(Tag {
+		name: "li",
+		args: vec![("class", status.into())],
+		children: Vec::new(),
+		self_closing: false,
+	    });
+
+	    self.compile_string(rest);
+
+	    self.pop();
+	} else if line.starts_with("-") {
+	    let rest = line[1..].trim();
+
+	    if self.mode != Mode::List {
+		self.set_mode(Mode::List);
+		self.stack.push(Tag {
+		    name: "ul",
+		    args: Vec::new(),
+		    children: Vec::new(),
+		    self_closing: false,
+		});
+	    }
+
+	    self.stack.push(Tag {
+		name: "li",
+		args: Vec::new(),
+		children: Vec::new(),
+		self_closing: false,
+	    });
+
+	    // TODO: TODO List
+	    self.compile_string(rest);
+
+	    self.pop();
+	} else if line.trim().is_empty() {
+	    self.set_mode(Mode::Normal);
+	} else {
+	    if self.mode != Mode::Paragraph {
+		self.set_mode(Mode::Paragraph);
+		self.stack.push(Tag::new("p"));
+	    } else {
+		self.push(Element::String(String::new()));
+	    }
+
+	    let (line, br) = line.trim().strip_suffix('\\').map_or((line, false), |l| (l, true));
+	    self.compile_string(line);
+	    if br {
+		let mut tag = Tag::new("br");
+		tag.self_closing = true;
+		self.push(Element::Tag(tag));
+	    }
+	}
+    }
+    fn compile_metadata(it: &mut Peekable<Lines>) -> Metadata {
+	let mut meta = Metadata::default();
+
+	while it.next_if(|s| s.trim().is_empty()).is_some() {}
+
+	if it.next_if(|s| s.trim() == "---").is_some() {
+	    while let Some(s) = it.next() {
+		let s = s.trim();
+		if s == "---" {
+		    break;
+		}
+
+		let Some((name, value)) = s.split_once(':') else { continue };
+		let name = name.trim();
+		let value = value.trim();
+
+		match name {
+		    "title"	=> meta.title = Some(value.into()),
+		    "date"	=> meta.date = NaiveDate::parse_from_str(value, "%Y-%m-%d").ok(),
+		    _		=> {},
+		}
+	    }
+	}
+
+	meta
+    }
+    fn compile(&mut self, text: &str) {
+	let mut it = text.lines().peekable();
+
+	self.meta = Self::compile_metadata(&mut it);
+	
+	for line in it {
+	    self.compile_line(line);
+	}
+    }
+}
+
+pub fn compile(text: &str) -> Result<Markdown> {
+    let mut compiler = Compiler::new();
+    
+    compiler.compile(text);
+    
+    compiler.set_mode(Mode::Normal);
+    
+    if compiler.stack.len() != 1 {
+	dbg!(compiler.stack);
+	panic!("failed to compile markdown");
+    }
+
+    let md = Markdown {
+	body: Element::Tag(compiler.stack.pop().unwrap()),
+	meta: compiler.meta,
+    };
+    Ok(md)
+}
+
+pub fn metadata(text: &str) -> Metadata {
+    let mut it = text.lines().peekable();
+    Compiler::compile_metadata(&mut it)
+}
blob - d94c14ec7f2b62b5fe3b73ee78cf4d02798472af
blob + 4029789b69a343a46cdd3b6d63b192cafe5415b3
--- src/style.css
+++ src/style.css
@@ -1,3 +1,17 @@
 a {
     color: dodgerblue;
 }
+
+.markdown .todo .open {
+    color: red;
+}
+
+
+.markdown .todo .done {
+    color: green;
+}
+
+#markdown-date {
+    font-size: 0.8rem;
+    color: gray;
+}
blob - /dev/null
blob + 7bedcdc0bf2a3c12294b2082d086c7a6697774e3 (mode 644)
Binary files /dev/null and www-cgi/cats/1.jpg differ
blob - /dev/null
blob + a79e1b8932a75749300d618aa171a0b6c7625a7d (mode 644)
Binary files /dev/null and www-cgi/cats/10.jpg differ
blob - /dev/null
blob + b43f918169ce12d67f1cdc0d44a1b3689f7fdedb (mode 644)
Binary files /dev/null and www-cgi/cats/11.jpg differ
blob - /dev/null
blob + 4998b5f31b54224588e6ee9d5974579c40a40d36 (mode 644)
Binary files /dev/null and www-cgi/cats/12.jpg differ
blob - /dev/null
blob + cf7ba488e8766077d7ef10110ca61ee025958c55 (mode 644)
Binary files /dev/null and www-cgi/cats/13.jpg differ
blob - /dev/null
blob + e4953de7b4091c16e7894961daf2bce4b0f555e0 (mode 644)
Binary files /dev/null and www-cgi/cats/14.jpg differ
blob - /dev/null
blob + 9574078edfb79338cc2caba1ed8da6ddf40b6924 (mode 644)
Binary files /dev/null and www-cgi/cats/15.jpg differ
blob - /dev/null
blob + 323579dd34f4be587b146451ea83b6dc6795e17c (mode 644)
Binary files /dev/null and www-cgi/cats/16.jpg differ
blob - /dev/null
blob + 44d2bbaa1d866c126595a702ffd1e9c14fc60266 (mode 644)
Binary files /dev/null and www-cgi/cats/17.jpg differ
blob - /dev/null
blob + 2bea3e4213191917739baa4a2d32cfb91aa77693 (mode 644)
Binary files /dev/null and www-cgi/cats/18.jpg differ
blob - /dev/null
blob + dae59c6e7706d225d073c4cf7b5a0cf440a79c90 (mode 644)
Binary files /dev/null and www-cgi/cats/19.jpg differ
blob - /dev/null
blob + 8375a0240277de48131bef446d7959b128d1fbf1 (mode 644)
Binary files /dev/null and www-cgi/cats/2.jpg differ
blob - /dev/null
blob + b96e4ba3518b475dfb8a79259ea7ae9b6cbb46c1 (mode 644)
Binary files /dev/null and www-cgi/cats/20.jpg differ
blob - /dev/null
blob + 376527b16c4236a21db689c093f99ccd69735e8f (mode 644)
Binary files /dev/null and www-cgi/cats/21.jpg differ
blob - /dev/null
blob + bb269d0a6b70ff58bbca3725fb11af3b75acc093 (mode 644)
Binary files /dev/null and www-cgi/cats/22.jpg differ
blob - /dev/null
blob + 50930ac894fcc6eaaa24e0d53cc38a7765df4128 (mode 644)
Binary files /dev/null and www-cgi/cats/23.jpg differ
blob - /dev/null
blob + 44720e2cea2e70ca2b5ed8fd8edafc38b29eb8d8 (mode 644)
Binary files /dev/null and www-cgi/cats/24.jpg differ
blob - /dev/null
blob + 5a76b8cffec005fe05721a6e8085e223814c4c41 (mode 644)
Binary files /dev/null and www-cgi/cats/25.jpg differ
blob - /dev/null
blob + 9410cad4d5034dc88b56eddff546cf8d443de7e0 (mode 644)
Binary files /dev/null and www-cgi/cats/26.jpg differ
blob - /dev/null
blob + bdd3117974a1753c25328db71767e62da730af6e (mode 644)
Binary files /dev/null and www-cgi/cats/27.jpg differ
blob - /dev/null
blob + f870d288d92e6d96ecab53902d0d40654a31ad1a (mode 644)
Binary files /dev/null and www-cgi/cats/28.jpg differ
blob - /dev/null
blob + 220d0e4135a019f04d308cdaba8d63429f9643cb (mode 644)
Binary files /dev/null and www-cgi/cats/3.jpg differ
blob - /dev/null
blob + eb672b6b24d6368995be0cbf5f17411867799ba7 (mode 644)
Binary files /dev/null and www-cgi/cats/4.jpg differ
blob - /dev/null
blob + c4882f6eef556b98b614c1b80b93ea797c80989d (mode 644)
Binary files /dev/null and www-cgi/cats/5.jpg differ
blob - /dev/null
blob + 82c9d90af5cf146e0ace8fc3a1d84f4baa0c9d20 (mode 644)
Binary files /dev/null and www-cgi/cats/6.jpg differ
blob - /dev/null
blob + f604acadf017b7a32322742608336ad5b13201d9 (mode 644)
Binary files /dev/null and www-cgi/cats/7.jpg differ
blob - /dev/null
blob + d61e8544fe298c2f82aab8de4c4bbb81b1d13ada (mode 644)
Binary files /dev/null and www-cgi/cats/8.jpg differ
blob - /dev/null
blob + 08bfbfab54d01d524ee06f8c03fb43c5720752ab (mode 644)
Binary files /dev/null and www-cgi/cats/9.jpg differ
blob - /dev/null
blob + 9553ae49e957dc45fe0ab1dfe070911fd07c60e1 (mode 755)
--- /dev/null
+++ www-cgi/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 + 3eb70d1d2df8aeabbbb94e30b7e81dcee593d574 (mode 644)
--- /dev/null
+++ www-cgi/posts/projects.md
@@ -0,0 +1,29 @@
+---
+title: Benjamin Stürz' project list
+date: 2024-05-02
+---
+
+# My Projects List
+
+- [read-only FUSE driver for UFS](https://got.stuerz.xyz/?action=summary&path=fuse-ufs.git) (GSoC 2024)
+- [64-Bit Linux Userspace RISC-V Emulator](https://got.stuerz.xyz/?action=summary&path=linurv.git) (2024)
+- [16-Bit custom CPU written in Verilog](https://got.stuerz.xyz/?action=summary&path=cpu.git) (2024)
+- [procfs FUSE implementation for OpenBSD](https://got.stuerz.xyz/?action=summary&path=procfs-fuse.git) (2024)
+- [This CGI script](https://got.stuerz.xyz/?action=summary&path=www-cgi.git) (2024)
+- [Minimal implementation of make(1) written in awk(1)](https://got.stuerz.xyz/?action=summary&path=junk.git) (2024)
+- [Rust Accounting Solution](https://got.stuerz.xyz/?action=summary&path=accounthing2.git) (2023-2024)
+- [Clone of lsblk(8) for OpenBSD](https://got.stuerz.xyz/?action=summary&path=lsblk.git) (2023)
+- [C++ std::format-like standalone header library](https://github.com/riscygeek/safmatlib) (2022)
+- [read-only tarfs FUSE implementation](https://github.com/riscygeek/fuse-tar) (2021)
+- [Minimal implementation of POSIX userspace for Linux](https://github.com/riscygeek/microcoreutils) (2021)
+- [Brainlet C Compiler](https://github.com/riscygeek/bcc) (2021-2022)
+- [32-Bit RISC-V CPU written in Verilog](https://github.com/riscygeek/rv32-cpu) (2020-2021)
+- [Compiler for a B-like language targeting i386-linux](https://github.com/riscygeek/benc) (2020)
+- [Ben Eater's 8-Bit CPU written in Verilog](https://github.com/riscygeek/eater-8bit) (2020)
+- [Assembler & VM for the Cosmos CP1](https://github.com/riscygeek/Cosmos-CP1) (2020)
+- [My first virtual machine for an imaginary CPU architecture](https://github.com/riscygeek/MicroVM-8) (2018)
+- [Minecraft Spigot Plugin for automatic tool legeling](https://github.com/riscygeek/ToolLeveling) (2017)
+- [My first attempt at creating a programming language](https://github.com/riscygeek/CAlpha) (2017)
+- Fake Windows XP written in Lua for the [Computercraft](https://www.computercraft.info/) Minecraft mod (lost, before 2017)
+- [Multiple](https://github.com/riscygeek/BenOS) [attempts](https://github.com/riscygeek/nameless-os) [to](https://github.com/riscygeek/micro-linux) [create](https://gitlab.com/overlay-linux) an operating system (incomplete)
+- Many more interesting projects, that I unfortunately lost over time...
blob - /dev/null
blob + e8bc60d441fd8e0eb85dd19340bcdc57a9e271d3 (mode 644)
--- /dev/null
+++ www-cgi/posts/test.md
@@ -0,0 +1,68 @@
+---
+title: Example Markdown Document
+date: 2024-05-02
+---
+
+# Hello World
+
+This is a test markdown document.
+[Link to index](/test/)
+
+This is some text \
+in a new paragraph. \
+The lines are split with the `/` character.
+
+This is a normal paragraph.
+The sentences are not split across lines,
+eventhough each sentence appears on it's own line.
+
+## Second heading
+
+### Third heading
+*italic*
+**bold**
+_underlined_
+`monospace`
+```
+monospace
+code block
+with
+multiple
+lines
+```
+
+### List
+- A
+- B
+- C
+- D
+- E
+- F
+
+# Supported features
+
+- [x] Paragraphs
+- [x] Headings
+- [x] **bold** text
+- [x] *italic* text
+- [x] _underlined_ text
+- [x] ~strokethrough~ text
+- [x] `monospace` text
+- [x] Line splitting with the `/` character
+- [x] Code blocks
+- [x] Links
+- [x] Escaping HTML sequences: `<>&`
+- [x] Lists
+- [x] TODO Lists
+- [x] Metadata
+- [ ] Images
+- [ ] Tables
+- [ ] Syntax Highlighting in Code Blocks with [tree-sitter](https://github.com/tree-sitter/tree-sitter-rust)
+
+# Metadata Format
+```
+---
+title: Example Title
+date: 2024-05-02
+---
+```