Commit Diff


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 <category>
+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 <build|serve|new [title]>"),
+        Some("-V") => println!("kssg {}", env!("KSSG_VERSION")),
+        _ => eprintln!("usage: kssg [-V] <build|serve|new [title]>"),
     }
 }
 
@@ -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<String>,
     part: Option<u32>,
+    tags: Vec<String>,
 }
 
 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("<br>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!(
+                        "<a href=\"/tags/{}.html\">{}</a>",
+                        slug, tag
+                    ));
+                }
+            }
             content.push_str("</p>\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("<item>\n");
+        xml.push_str(&format!("<title>{}</title>\n", post.meta.title,));
         xml.push_str(&format!(
-            "<title>{}</title>\n",
-            post.meta.title,
-        ));
-        xml.push_str(&format!(
             "<link>{}/{}</link>\n",
             SITE_URL,
             href.display(),
@@ -614,10 +627,10 @@ fn generate_rss(posts: &[Post]) {
             "<description>{}</description>\n",
             post.meta.description,
         ));
-        xml.push_str(&format!(
-            "<pubDate>{}</pubDate>\n",
-            post.meta.date,
-        ));
+        xml.push_str(&format!("<pubDate>{}</pubDate>\n", post.meta.date,));
+        for tag in &post.meta.tags {
+            xml.push_str(&format!("<category>{}</category>\n", tag,));
+        }
         xml.push_str("</item>\n");
     }
 
@@ -633,10 +646,7 @@ fn generate_sitemap(posts: &[Post]) {
     );
 
     // index
-    xml.push_str(&format!(
-        "<url><loc>{}/</loc></url>\n",
-        SITE_URL,
-    ));
+    xml.push_str(&format!("<url><loc>{}/</loc></url>\n", SITE_URL,));
 
     for post in posts {
         let href = post
@@ -651,12 +661,114 @@ fn generate_sitemap(posts: &[Post]) {
         ));
     }
 
+    let mut tag_slugs: Vec<String> = 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!("<url><loc>{}/tags/</loc></url>\n", SITE_URL,));
+        for slug in &tag_slugs {
+            xml.push_str(&format!(
+                "<url><loc>{}/tags/{}.html</loc></url>\n",
+                SITE_URL, slug,
+            ));
+        }
+    }
+
     xml.push_str("</urlset>\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<String, Vec<&Post>> = HashMap::new();
+    let mut tag_names: HashMap<String, String> = 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!("<h1>{}</h1>\n<ul>\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!(
+                "<li><time>{}</time> - \
+                 <a href=\"{}\">{}</a></li>\n",
+                post.meta.date, href, post.meta.title,
+            ));
+        }
+        list.push_str("</ul>\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("<h1>Tags</h1>\n<ul>\n");
+    for slug in &sorted_slugs {
+        let name = &tag_names[*slug];
+        let count = tags[*slug].len();
+        index.push_str(&format!(
+            "<li><a href=\"/tags/{}.html\">{}</a> ({})</li>\n",
+            slug, name, count,
+        ));
+    }
+    index.push_str("</ul>\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!(
             "<a href=\"{}\">\u{2190} {}</a>",
@@ -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!(
             "<a href=\"{}\">{} \u{2192}</a>",
@@ -774,9 +892,7 @@ fn watch_paths() -> Vec<PathBuf> {
 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<PathBuf> = Vec::new();
 
-    fn register_new_dirs(
-        kq: i32,
-        watched: &mut Vec<PathBuf>,
-    ) {
+    fn register_new_dirs(kq: i32, watched: &mut Vec<PathBuf>) {
         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;