commit 35eedcc793044f4b76e047be8cd1a1b0879ce771 from: Murilo Ijanc date: Fri Apr 10 20:15:21 2026 UTC add tags, version flag, allow raw html in markdown commit - e5faee711dde9ccd70a0af34222cf6b80bb09331 commit + 35eedcc793044f4b76e047be8cd1a1b0879ce771 blob - dd6071ebec4f1e79ccada5b7d0a1810ff641b332 blob + 9d56681d93dde558b84617d36a3e531ff3d02408 --- kssg.1 +++ kssg.1 @@ -13,7 +13,7 @@ .\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF .\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. .\" -.Dd $Mdocdate: April 7 2026 $ +.Dd $Mdocdate: April 8 2026 $ .Dt KSSG 1 .Os .Sh NAME @@ -21,6 +21,8 @@ .Nd KISS static site generator .Sh SYNOPSIS .Nm kssg +.Fl V +.Nm kssg .Cm build .Nm kssg .Cm serve @@ -35,6 +37,12 @@ The generated pages contain no JavaScript and work in such as .Xr lynx 1 . .Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl V +Print the version and exit. +.El +.Pp The commands are as follows: .Bl -tag -width Ds .It Cm build @@ -89,6 +97,7 @@ title: Post title date: 2026-04-07 updated: 2026-04-08 description: Short description +tags: rust, openbsd, web draft: false series: series-name part: 1 @@ -107,6 +116,16 @@ Optional. .It Cm description Meta description for HTML head. Optional. +.It Cm tags +Comma-separated list of tags. +Each tag generates a page at +.Pa public/tags/{slug}.html . +An index of all tags is generated at +.Pa public/tags/index.html . +Tags are included as +.Cm +elements in the RSS feed. +Optional. .It Cm draft If .Cm true , @@ -156,10 +175,15 @@ Files copied to the root of .It Pa public/ Generated output directory. Cleaned before each build. +.It Pa public/tags/ +Tag pages. +Each tag has a page listing its posts. +.It Pa public/tags/index.html +Alphabetical listing of all tags. .It Pa public/rss.xml RSS feed with the last 20 posts. .It Pa public/sitemap.xml -XML sitemap. +XML sitemap, including tag pages. .El .Sh EXIT STATUS .Ex -std kssg blob - 0dba79c503e16852493e25c6afb67f1144a42bfd blob + b0996c1df54f66f569fe8cf420ca1d655e388c47 --- kssg.rs +++ kssg.rs @@ -22,8 +22,8 @@ use std::{ net::{TcpListener, TcpStream}, path::{Path, PathBuf}, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, OnceLock, + atomic::{AtomicBool, AtomicU64, Ordering}, }, }; @@ -39,7 +39,8 @@ fn main() { serve(); } Some("new") => new_post(args.get(2).map(|s| s.as_str())), - _ => eprintln!("usage: kssg "), + Some("-V") => println!("kssg {}", env!("KSSG_VERSION")), + _ => eprintln!("usage: kssg [-V] "), } } @@ -113,10 +114,7 @@ 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); @@ -130,10 +128,7 @@ 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"); } @@ -201,10 +196,7 @@ fn handle_sse(mut stream: TcpStream) { if current != last { last = current; log!("sse sending reload"); - if stream - .write_all(b"data: reload\n\n") - .is_err() - { + if stream.write_all(b"data: reload\n\n").is_err() { break; } } @@ -264,6 +256,7 @@ struct Metadata { draft: bool, series: Option, part: Option, + tags: Vec, } fn parse_options() -> markdown::ParseOptions { @@ -291,10 +284,7 @@ fn parse_frontmatter(content: &str) -> Metadata { .strip_prefix('"') .and_then(|s| s.strip_suffix('"')) .unwrap_or(val); - map.insert( - k.trim().to_string(), - val.to_string(), - ); + map.insert(k.trim().to_string(), val.to_string()); } } } @@ -309,6 +299,15 @@ fn parse_frontmatter(content: &str) -> Metadata { 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()), + tags: map + .remove("tags") + .map(|v| { + v.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() + }) + .unwrap_or_default(), } } @@ -437,6 +436,7 @@ fn new_post(arg_title: Option<&str>) { title: {}\n\ date: {}\n\ description: \n\ + tags: \n\ draft: true\n\ ---\n\n\ # {}\n", @@ -494,7 +494,10 @@ fn build() { &md, &markdown::Options { parse: parse_options(), - ..markdown::Options::default() + compile: markdown::CompileOptions { + allow_dangerous_html: true, + ..markdown::CompileOptions::default() + }, }, ) .expect("markdown to html"); @@ -529,6 +532,19 @@ fn build() { posts[i].meta.updated, )); } + if !posts[i].meta.tags.is_empty() { + content.push_str("
Tags: "); + for (j, tag) in posts[i].meta.tags.iter().enumerate() { + if j > 0 { + content.push_str(", "); + } + let slug = slugify(tag); + content.push_str(&format!( + "{}", + slug, tag + )); + } + } content.push_str("

\n"); } @@ -569,10 +585,10 @@ fn build() { let page_404 = Path::new("templates/404.html"); if page_404.exists() { - fs::copy(page_404, "public/404.html") - .expect("copy 404.html"); + fs::copy(page_404, "public/404.html").expect("copy 404.html"); } + generate_tag_pages(&posts, &template); generate_rss(&posts); generate_sitemap(&posts); @@ -601,11 +617,8 @@ fn generate_rss(posts: &[Post]) { .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", - post.meta.title, - )); - xml.push_str(&format!( "{}/{}\n", SITE_URL, href.display(), @@ -614,10 +627,10 @@ fn generate_rss(posts: &[Post]) { "{}\n", post.meta.description, )); - xml.push_str(&format!( - "{}\n", - post.meta.date, - )); + xml.push_str(&format!("{}\n", post.meta.date,)); + for tag in &post.meta.tags { + xml.push_str(&format!("{}\n", tag,)); + } xml.push_str("\n"); } @@ -633,10 +646,7 @@ fn generate_sitemap(posts: &[Post]) { ); // index - xml.push_str(&format!( - "{}/\n", - SITE_URL, - )); + xml.push_str(&format!("{}/\n", SITE_URL,)); for post in posts { let href = post @@ -651,12 +661,114 @@ fn generate_sitemap(posts: &[Post]) { )); } + let mut tag_slugs: Vec = Vec::new(); + for post in posts { + for tag in &post.meta.tags { + let slug = slugify(tag); + if !tag_slugs.contains(&slug) { + tag_slugs.push(slug); + } + } + } + tag_slugs.sort(); + + if !tag_slugs.is_empty() { + xml.push_str(&format!("{}/tags/\n", SITE_URL,)); + for slug in &tag_slugs { + xml.push_str(&format!( + "{}/tags/{}.html\n", + SITE_URL, slug, + )); + } + } + xml.push_str("\n"); - fs::write("public/sitemap.xml", &xml) - .expect("write sitemap.xml"); + fs::write("public/sitemap.xml", &xml).expect("write sitemap.xml"); log!("wrote public/sitemap.xml"); } +fn generate_tag_pages(posts: &[Post], template: &str) { + let mut tags: HashMap> = HashMap::new(); + let mut tag_names: HashMap = HashMap::new(); + + for post in posts { + if !post.out_path.starts_with("public/posts") { + continue; + } + for tag in &post.meta.tags { + let slug = slugify(tag); + tags.entry(slug.clone()).or_default().push(post); + tag_names.entry(slug).or_insert_with(|| tag.clone()); + } + } + + if tags.is_empty() { + return; + } + + fs::create_dir_all("public/tags").expect("create tags dir"); + + let mut sorted_slugs: Vec<&String> = tags.keys().collect(); + sorted_slugs.sort(); + + for slug in &sorted_slugs { + let name = &tag_names[*slug]; + let items = &tags[*slug]; + + let mut list = String::new(); + let mut sorted: Vec<&&Post> = items.iter().collect(); + sorted.sort_by(|a, b| b.meta.date.cmp(&a.meta.date)); + + list.push_str(&format!("

{}

\n
    \n", name)); + for post in &sorted { + let href = format!( + "/{}", + post.out_path + .strip_prefix("public") + .unwrap_or(&post.out_path) + .display(), + ); + list.push_str(&format!( + "
  • - \ + {}
  • \n", + post.meta.date, href, post.meta.title, + )); + } + list.push_str("
\n"); + + let output = template + .replace("{{title}}", &format!("tag: {}", name)) + .replace("{{description}}", &format!("posts com tag {}", name)) + .replace("{{content}}", &list) + .replace("{{posts}}", ""); + + let path = format!("public/tags/{}.html", slug); + fs::write(&path, &output).expect("write tag page"); + log!("wrote {}", path); + } + + // tag index + let mut index = String::from("

Tags

\n
    \n"); + for slug in &sorted_slugs { + let name = &tag_names[*slug]; + let count = tags[*slug].len(); + index.push_str(&format!( + "
  • {} ({})
  • \n", + slug, name, count, + )); + } + index.push_str("
\n"); + + let output = template + .replace("{{title}}", "tags") + .replace("{{description}}", "todas as tags") + .replace("{{content}}", &index) + .replace("{{posts}}", ""); + + fs::write("public/tags/index.html", &output).expect("write tags index"); + log!("wrote public/tags/index.html"); +} + fn series_nav(posts: &[Post], current: usize) -> String { let series = match &posts[current].meta.series { Some(s) => s, @@ -691,7 +803,10 @@ fn series_nav(posts: &[Post], current: usize) -> Strin if let Some(p) = prev { let href = format!( "/{}", - p.out_path.strip_prefix("public").unwrap_or(&p.out_path).display(), + p.out_path + .strip_prefix("public") + .unwrap_or(&p.out_path) + .display(), ); nav.push_str(&format!( "\u{2190} {}", @@ -702,7 +817,10 @@ fn series_nav(posts: &[Post], current: usize) -> Strin if let Some(p) = next { let href = format!( "/{}", - p.out_path.strip_prefix("public").unwrap_or(&p.out_path).display(), + p.out_path + .strip_prefix("public") + .unwrap_or(&p.out_path) + .display(), ); nav.push_str(&format!( "{} \u{2192}", @@ -774,9 +892,7 @@ fn watch_paths() -> Vec { fn watch_and_rebuild() { unsafe extern "C" { fn inotify_init() -> i32; - fn inotify_add_watch( - fd: i32, path: *const i8, mask: u32, - ) -> i32; + fn inotify_add_watch(fd: i32, path: *const i8, mask: u32) -> i32; fn read(fd: i32, buf: *mut u8, count: usize) -> isize; } @@ -793,13 +909,10 @@ fn watch_and_rebuild() { fn add_watches(fd: i32, mask: u32) { for dir in watch_paths() { - let cstr = std::ffi::CString::new( - dir.to_str().expect("path to str"), - ) - .expect("cstring"); - let wd = unsafe { - inotify_add_watch(fd, cstr.as_ptr(), mask) - }; + let cstr = + std::ffi::CString::new(dir.to_str().expect("path to str")) + .expect("cstring"); + let wd = unsafe { inotify_add_watch(fd, cstr.as_ptr(), mask) }; if wd < 0 { eprintln!("watch failed: {}", dir.display()); } else { @@ -813,9 +926,7 @@ fn watch_and_rebuild() { let mut buf = [0u8; 4096]; loop { - let n = unsafe { - read(fd, buf.as_mut_ptr(), buf.len()) - }; + let n = unsafe { read(fd, buf.as_mut_ptr(), buf.len()) }; if n <= 0 { continue; } @@ -865,8 +976,7 @@ fn watch_and_rebuild() { const NOTE_DELETE: u32 = 0x0001; const NOTE_RENAME: u32 = 0x0020; const O_RDONLY: i32 = 0; - const FFLAGS: u32 = - NOTE_WRITE | NOTE_DELETE | NOTE_RENAME; + const FFLAGS: u32 = NOTE_WRITE | NOTE_DELETE | NOTE_RENAME; let kq = unsafe { kqueue() }; if kq < 0 { @@ -876,10 +986,7 @@ fn watch_and_rebuild() { let mut watched: Vec = Vec::new(); - fn register_new_dirs( - kq: i32, - watched: &mut Vec, - ) { + fn register_new_dirs(kq: i32, watched: &mut Vec) { use std::ffi::CString; unsafe extern "C" { @@ -901,17 +1008,14 @@ fn watch_and_rebuild() { const NOTE_DELETE: u32 = 0x0001; const NOTE_RENAME: u32 = 0x0020; const O_RDONLY: i32 = 0; - const FFLAGS: u32 = - NOTE_WRITE | NOTE_DELETE | NOTE_RENAME; + const FFLAGS: u32 = NOTE_WRITE | NOTE_DELETE | NOTE_RENAME; for dir in watch_paths() { if watched.contains(&dir) { continue; } - let cstr = CString::new( - dir.to_str().expect("path to str"), - ) - .expect("cstring"); + let cstr = CString::new(dir.to_str().expect("path to str")) + .expect("cstring"); let fd = unsafe { open(cstr.as_ptr(), O_RDONLY) }; if fd < 0 { eprintln!("open failed: {}", dir.display()); @@ -926,11 +1030,7 @@ fn watch_and_rebuild() { udata: std::ptr::null_mut(), }; unsafe { - kevent( - kq, &ev, 1, - std::ptr::null_mut(), 0, - std::ptr::null(), - ); + kevent(kq, &ev, 1, std::ptr::null_mut(), 0, std::ptr::null()); } log!("watching {}", dir.display()); watched.push(dir); @@ -951,14 +1051,7 @@ fn watch_and_rebuild() { loop { let n = unsafe { - kevent( - kq, - std::ptr::null(), - 0, - &mut event, - 1, - std::ptr::null(), - ) + kevent(kq, std::ptr::null(), 0, &mut event, 1, std::ptr::null()) }; if n <= 0 { continue;