commit - e5faee711dde9ccd70a0af34222cf6b80bb09331
commit + 35eedcc793044f4b76e047be8cd1a1b0879ce771
blob - dd6071ebec4f1e79ccada5b7d0a1810ff641b332
blob + 9d56681d93dde558b84617d36a3e531ff3d02408
--- kssg.1
+++ kssg.1
.\" 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
.Nd KISS static site generator
.Sh SYNOPSIS
.Nm kssg
+.Fl V
+.Nm kssg
.Cm build
.Nm kssg
.Cm serve
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
date: 2026-04-07
updated: 2026-04-08
description: Short description
+tags: rust, openbsd, web
draft: false
series: series-name
part: 1
.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 ,
.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
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
sync::{
- atomic::{AtomicBool, AtomicU64, Ordering},
OnceLock,
+ atomic::{AtomicBool, AtomicU64, Ordering},
},
};
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]>"),
}
}
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);
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");
}
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;
}
}
draft: bool,
series: Option<String>,
part: Option<u32>,
+ tags: Vec<String>,
}
fn parse_options() -> markdown::ParseOptions {
.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());
}
}
}
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(),
}
}
title: {}\n\
date: {}\n\
description: \n\
+ tags: \n\
draft: true\n\
---\n\n\
# {}\n",
&md,
&markdown::Options {
parse: parse_options(),
- ..markdown::Options::default()
+ compile: markdown::CompileOptions {
+ allow_dangerous_html: true,
+ ..markdown::CompileOptions::default()
+ },
},
)
.expect("markdown to html");
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");
}
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);
.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(),
"<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");
}
);
// 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
));
}
+ 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,
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>",
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>",
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;
}
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 {
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;
}
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 {
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" {
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());
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);
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;