commit - 5bf6d120ef53d18feb4cefb6be97b47a408da166
commit + d4e8dba86b211177a1d053dbadb608da783f6c18
blob - fc0efeb04981353d47fd06c87543b0644e2da615
blob + 4a3f35186e4da66bf133cb2ed4530ada077fa057
--- src/main.rs
+++ src/main.rs
env, fs,
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
- sync::OnceLock,
+ sync::{
+ atomic::{AtomicU64, Ordering},
+ OnceLock,
+ },
};
fn main() {
//////////////////////////////////////////////////////////////////////////////
static LOG_ENABLED: OnceLock<bool> = OnceLock::new();
+static BUILD_GEN: AtomicU64 = AtomicU64::new(0);
macro_rules! log {
($($arg:tt)*) => {
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};
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..]);
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!(
}
//////////////////////////////////////////////////////////////////////////////
+// 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
//////////////////////////////////////////////////////////////////////////////
// Frontmatter
//////////////////////////////////////////////////////////////////////////////
-#[allow(dead_code)]
struct Metadata {
title: String,
date: String,
+ description: String,
draft: bool,
series: Option<String>,
part: Option<u32>,
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()),
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");
let output = template
.replace("{{title}}", &posts[i].meta.title)
+ .replace("{{description}}", &posts[i].meta.description)
.replace("{{content}}", &content)
.replace("{{posts}}", list);
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,
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
}
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
<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
+<!-- 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>