Commit Diff


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<bool> = 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 = "<script>\
+    new EventSource('/sse').onmessage=function(){location.reload()};\
+    </script>";
+
 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("</body>", &format!("{SSE_SCRIPT}</body>"));
+            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<String>,
     part: Option<u32>,
@@ -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(
+        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
+         <rss version=\"2.0\">\n<channel>\n\
+         <title>ijanc.org</title>\n\
+         <link>https://ijanc.org</link>\n\
+         <description>ijanc.org</description>\n",
+    );
+
+    for post in items.iter().take(20) {
+        let href = post
+            .out_path
+            .strip_prefix("public")
+            .unwrap_or(&post.out_path);
+        xml.push_str("<item>\n");
+        xml.push_str(&format!(
+            "<title>{}</title>\n",
+            post.meta.title,
+        ));
+        xml.push_str(&format!(
+            "<link>{}/{}</link>\n",
+            SITE_URL,
+            href.display(),
+        ));
+        xml.push_str(&format!(
+            "<description>{}</description>\n",
+            post.meta.description,
+        ));
+        xml.push_str(&format!(
+            "<pubDate>{}</pubDate>\n",
+            post.meta.date,
+        ));
+        xml.push_str("</item>\n");
+    }
+
+    xml.push_str("</channel>\n</rss>\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(
+        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
+         <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
+    );
+
+    // index
+    xml.push_str(&format!(
+        "<url><loc>{}/</loc></url>\n",
+        SITE_URL,
+    ));
+
+    for post in posts {
+        let href = post
+            .out_path
+            .strip_prefix("public")
+            .unwrap_or(&post.out_path);
+        xml.push_str(&format!(
+            "<url><loc>{}/{}</loc><lastmod>{}</lastmod></url>\n",
+            SITE_URL,
+            href.display(),
+            post.meta.date,
+        ));
+    }
+
+    xml.push_str("</urlset>\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("<nav class=\"series-nav\">");
 
     if let Some(p) = prev {
-        let href = format!("/{}", p.out_path.display());
+        let href = format!(
+            "/{}",
+            p.out_path.strip_prefix("public").unwrap_or(&p.out_path).display(),
+        );
         nav.push_str(&format!(
             "<a href=\"{}\">\u{2190} {}</a>",
             href, p.meta.title
@@ -402,7 +577,10 @@ fn series_nav(posts: &[Post], current: usize) -> Strin
     }
 
     if let Some(p) = next {
-        let href = format!("/{}", p.out_path.display());
+        let href = format!(
+            "/{}",
+            p.out_path.strip_prefix("public").unwrap_or(&p.out_path).display(),
+        );
         nav.push_str(&format!(
             "<a href=\"{}\">{} \u{2192}</a>",
             href, p.meta.title
blob - fdb4c3fe39d11fb55fa91e457e9720f2940afc07
blob + 9257329ed59fbce876f6fa8e8460cfd5f7602f76
--- templates/base.html
+++ templates/base.html
@@ -4,6 +4,7 @@
   <head>
     <meta charset="utf-8">
     <title>{{title}}</title>
+    <meta name="description" content="{{description}}">
   </head>
   <body>
     {{content}}
blob - /dev/null
blob + 39e1901dc54abdb3d1413ce915f7c14eaaaf1ef7 (mode 644)
--- /dev/null
+++ templates/404.html
@@ -0,0 +1,13 @@
+<!-- vim: set tw=79 cc=80 ts=2 sw=2 sts=2 et ft=html : -->
+<!DOCTYPE html>
+<html lang="pt-BR">
+  <head>
+    <meta charset="utf-8">
+    <title>404</title>
+  </head>
+  <body>
+    <h1>404</h1>
+    <p>Page not found.</p>
+    <a href="/">back</a>
+  </body>
+</html>