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;