commit d4e8dba86b211177a1d053dbadb608da783f6c18 from: Murilo Ijanc date: Sun Apr 5 21:57:37 2026 UTC clean public before build, fix series links, add rss, sitemap, meta description, custom 404 commit - 5bf6d120ef53d18feb4cefb6be97b47a408da166 commit + d4e8dba86b211177a1d053dbadb608da783f6c18 blob - fc0efeb04981353d47fd06c87543b0644e2da615 blob + 4a3f35186e4da66bf133cb2ed4530ada077fa057 --- src/main.rs +++ src/main.rs @@ -20,7 +20,10 @@ use std::{ env, fs, net::{TcpListener, TcpStream}, path::{Path, PathBuf}, - sync::OnceLock, + sync::{ + atomic::{AtomicU64, Ordering}, + OnceLock, + }, }; fn main() { @@ -40,6 +43,7 @@ fn main() { ////////////////////////////////////////////////////////////////////////////// static LOG_ENABLED: OnceLock = OnceLock::new(); +static BUILD_GEN: AtomicU64 = AtomicU64::new(0); macro_rules! log { ($($arg:tt)*) => { @@ -77,10 +81,16 @@ fn serve() { Ok(s) => s, Err(_) => continue, }; - handle_request(stream); + std::thread::spawn(|| { + handle_request(stream); + }); } } +const SSE_SCRIPT: &str = ""; + fn handle_request(mut stream: TcpStream) { use std::io::{BufRead, BufReader, Write}; @@ -93,8 +103,16 @@ fn handle_request(mut stream: TcpStream) { log!("{}", request_line); // "GET /path HTTP/1.1" - let path = request_line.split_whitespace().nth(1).unwrap_or("/"); + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("/"); + if path == "/sse" { + handle_sse(stream); + return; + } + let mut file_path = PathBuf::from("public"); file_path.push(&path[1..]); @@ -102,18 +120,36 @@ fn handle_request(mut stream: TcpStream) { file_path.push("index.html"); } if !file_path.exists() - && !file_path.to_str().map(|s| s.contains('.')).unwrap_or(false) + && !file_path + .to_str() + .map(|s| s.contains('.')) + .unwrap_or(false) { file_path.set_extension("html"); } let (status, body, ctype) = if file_path.exists() { - let body = fs::read(&file_path).expect("read file"); + let mut body = fs::read(&file_path).expect("read file"); let ctype = content_type(&file_path); + + // inject SSE script into HTML + if ctype.starts_with("text/html") { + let html = String::from_utf8_lossy(&body); + let injected = + html.replace("", &format!("{SSE_SCRIPT}")); + body = injected.into_bytes(); + } + ("200 OK", body, ctype) } else { - let body = b"404".to_vec(); - ("404 Not Found", body, "text/plain") + let body = fs::read("public/404.html") + .unwrap_or_else(|_| b"404 not found".to_vec()); + let ctype = if Path::new("public/404.html").exists() { + "text/html; charset=utf-8" + } else { + "text/plain" + }; + ("404 Not Found", body, ctype) }; let response = format!( @@ -129,6 +165,45 @@ fn handle_request(mut stream: TcpStream) { } ////////////////////////////////////////////////////////////////////////////// +// SSE / Live Reload +////////////////////////////////////////////////////////////////////////////// + +fn handle_sse(mut stream: TcpStream) { + use std::io::Write; + + let header = "HTTP/1.1 200 OK\r\n\ + Content-Type: text/event-stream\r\n\ + Cache-Control: no-cache\r\n\ + Connection: keep-alive\r\n\r\n"; + + if stream.write_all(header.as_bytes()).is_err() { + return; + } + + log!("sse client connected"); + + let mut last = BUILD_GEN.load(Ordering::Relaxed); + + loop { + std::thread::sleep(std::time::Duration::from_millis(250)); + + let current = BUILD_GEN.load(Ordering::Relaxed); + if current != last { + last = current; + log!("sse sending reload"); + if stream + .write_all(b"data: reload\n\n") + .is_err() + { + break; + } + } + } + + log!("sse client disconnected"); +} + +////////////////////////////////////////////////////////////////////////////// // DateTime ////////////////////////////////////////////////////////////////////////////// @@ -171,10 +246,10 @@ fn now_utc() -> String { // Frontmatter ////////////////////////////////////////////////////////////////////////////// -#[allow(dead_code)] struct Metadata { title: String, date: String, + description: String, draft: bool, series: Option, part: Option, @@ -210,6 +285,7 @@ fn parse_frontmatter(content: &str) -> Metadata { Metadata { title: map.remove("title").unwrap_or_default(), date: map.remove("date").unwrap_or_default(), + description: map.remove("description").unwrap_or_default(), draft: map.get("draft").map(|v| v == "true").unwrap_or(false), series: map.remove("series"), part: map.get("part").and_then(|v| v.parse().ok()), @@ -280,9 +356,17 @@ struct Post { out_path: PathBuf, } +const SITE_URL: &str = "https://ijanc.org"; + fn build() { log!("starting build"); + let public = Path::new("public"); + if public.exists() { + fs::remove_dir_all(public).expect("clean public/"); + log!("cleaned public/"); + } + let template = fs::read_to_string("templates/base.html") .expect("read templates/base.html"); @@ -343,6 +427,7 @@ fn build() { let output = template .replace("{{title}}", &posts[i].meta.title) + .replace("{{description}}", &posts[i].meta.description) .replace("{{content}}", &content) .replace("{{posts}}", list); @@ -356,12 +441,99 @@ fn build() { let static_dir = Path::new("static"); if static_dir.exists() { - copy_dir(static_dir, &Path::new("public/static")); + copy_dir(static_dir, Path::new("public/static")); } + let page_404 = Path::new("templates/404.html"); + if page_404.exists() { + fs::copy(page_404, "public/404.html") + .expect("copy 404.html"); + } + + generate_rss(&posts); + generate_sitemap(&posts); + + BUILD_GEN.fetch_add(1, Ordering::Relaxed); println!("build ok"); } +fn generate_rss(posts: &[Post]) { + let mut items: Vec<&Post> = posts + .iter() + .filter(|p| p.out_path != Path::new("public/index.html")) + .collect(); + items.sort_by(|a, b| b.meta.date.cmp(&a.meta.date)); + + let mut xml = String::from( + "\n\ + \n\n\ + ijanc.org\n\ + https://ijanc.org\n\ + ijanc.org\n", + ); + + for post in items.iter().take(20) { + let href = post + .out_path + .strip_prefix("public") + .unwrap_or(&post.out_path); + xml.push_str("\n"); + xml.push_str(&format!( + "{}\n", + post.meta.title, + )); + xml.push_str(&format!( + "{}/{}\n", + SITE_URL, + href.display(), + )); + xml.push_str(&format!( + "{}\n", + post.meta.description, + )); + xml.push_str(&format!( + "{}\n", + post.meta.date, + )); + xml.push_str("\n"); + } + + xml.push_str("\n\n"); + fs::write("public/rss.xml", &xml).expect("write rss.xml"); + log!("wrote public/rss.xml"); +} + +fn generate_sitemap(posts: &[Post]) { + let mut xml = String::from( + "\n\ + \n", + ); + + // index + xml.push_str(&format!( + "{}/\n", + SITE_URL, + )); + + for post in posts { + let href = post + .out_path + .strip_prefix("public") + .unwrap_or(&post.out_path); + xml.push_str(&format!( + "{}/{}{}\n", + SITE_URL, + href.display(), + post.meta.date, + )); + } + + xml.push_str("\n"); + fs::write("public/sitemap.xml", &xml) + .expect("write sitemap.xml"); + log!("wrote public/sitemap.xml"); +} + fn series_nav(posts: &[Post], current: usize) -> String { let series = match &posts[current].meta.series { Some(s) => s, @@ -394,7 +566,10 @@ fn series_nav(posts: &[Post], current: usize) -> Strin let mut nav = String::from("