Commit Diff


commit - d4e8dba86b211177a1d053dbadb608da783f6c18
commit + ca3ca403aa8ffe3108c43b202a7709e3bc043912
blob - 814a7a92844f5ca7540b13920185dadb11dec250
blob + 80b31097717545e53b8f84e85553f12bbde9c274
--- .gitignore
+++ .gitignore
@@ -1,2 +1,3 @@
-target
+build
 public
+vendor
blob - 594a4ecf89af40ed3c3ba7c072a4032fbc3cbd6c (mode 644)
blob + /dev/null
--- Cargo.lock
+++ /dev/null
@@ -1,25 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "kssg"
-version = "0.1.0"
-dependencies = [
- "markdown",
-]
-
-[[package]]
-name = "markdown"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
-dependencies = [
- "unicode-id",
-]
-
-[[package]]
-name = "unicode-id"
-version = "0.3.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580"
blob - 33a5e4587af9ca09e2ce6c3392d1b216cddd4834 (mode 644)
blob + /dev/null
--- Cargo.toml
+++ /dev/null
@@ -1,8 +0,0 @@
-[package]
-name = "kssg"
-version = "0.1.0"
-edition = "2024"
-authors = ["Murilo Ijanc' <murilo@ijanc.org>"]
-
-[dependencies]
-markdown = "=1.0.0"
blob - 76ef8853b9a6018a258a4674fd89a4c487ce98e7
blob + f1706dea36aaafa871d79206e3718aba1bf558a8
--- Makefile
+++ Makefile
@@ -1,37 +1,60 @@
-.PHONY: all check build build-release test fmt clippy lint doc clean install run
+RUSTC ?= $(shell rustup which rustc 2>/dev/null || which rustc)
+RUSTFLAGS ?= -C opt-level=2 -C strip=symbols
+CURL ?= curl
+PREFIX ?= /usr/local
+MANDIR ?= $(PREFIX)/share/man
 
-all: run
+BUILD = build
+BIN = $(BUILD)/kssg
 
-check:
-	cargo check
+CRATES_IO = https://crates.io/api/v1/crates
+MARKDOWN_VER = 1.0.0
+UNICODE_ID_VER = 0.3.6
 
-build:
-	cargo build
+UNICODE_ID = vendor/unicode-id/src/lib.rs
+MARKDOWN = vendor/markdown/src/lib.rs
+MAIN = kssg.rs
 
-build-release:
-	cargo build
+.PHONY: all clean install vendor
 
-test:
-	cargo test
+all: $(BIN)
 
-fmt:
-	cargo fmt
+$(BUILD)/libunicode_id.rlib: $(UNICODE_ID)
+	mkdir -p $(BUILD)
+	$(RUSTC) --edition 2021 --crate-type rlib \
+		--crate-name unicode_id $(RUSTFLAGS) \
+		-o $@ $<
 
-clippy:
-	cargo clippy
+$(BUILD)/libmarkdown.rlib: $(MARKDOWN) $(BUILD)/libunicode_id.rlib
+	TMPDIR=/tmp $(RUSTC) --edition 2018 --crate-type rlib \
+		--crate-name markdown $(RUSTFLAGS) \
+		-L $(BUILD) --extern unicode_id=$(BUILD)/libunicode_id.rlib \
+		-o $@ $<
 
-lint:
-	cargo fmt --check
-	cargo clippy --all-features -- -D warnings
+$(BIN): $(MAIN) $(BUILD)/libmarkdown.rlib
+	TMPDIR=/tmp $(RUSTC) --edition 2024 --crate-type bin \
+		--crate-name kssg $(RUSTFLAGS) \
+		-L $(BUILD) --extern markdown=$(BUILD)/libmarkdown.rlib \
+		-o $@ $<
 
-doc:
-	cargo doc
-
 clean:
-	cargo clean
+	rm -rf $(BUILD)
 
-install:
-	cargo install --path .
+install: $(BIN)
+	install -d $(PREFIX)/bin $(MANDIR)/man1
+	install -m 755 $(BIN) $(PREFIX)/bin/kssg
+	install -m 644 kssg.1 $(MANDIR)/man1/kssg.1
 
-run:
-	cargo run
+vendor: $(UNICODE_ID) $(MARKDOWN)
+
+$(UNICODE_ID):
+	mkdir -p vendor
+	$(CURL) -sL $(CRATES_IO)/unicode-id/$(UNICODE_ID_VER)/download \
+		| tar xz -C vendor
+	mv vendor/unicode-id-$(UNICODE_ID_VER) vendor/unicode-id
+
+$(MARKDOWN):
+	mkdir -p vendor
+	$(CURL) -sL $(CRATES_IO)/markdown/$(MARKDOWN_VER)/download \
+		| tar xz -C vendor
+	mv vendor/markdown-$(MARKDOWN_VER) vendor/markdown
blob - /dev/null
blob + dd6071ebec4f1e79ccada5b7d0a1810ff641b332 (mode 644)
--- /dev/null
+++ kssg.1
@@ -0,0 +1,191 @@
+.\"
+.\" Copyright (c) 2025-2026 Murilo Ijanc' <murilo@ijanc.org>
+.\"
+.\" Permission to use, copy, modify, and/or distribute this software for any
+.\" purpose with or without fee is hereby granted, provided that the above
+.\" copyright notice and this permission notice appear in all copies.
+.\"
+.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+.\" 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 $
+.Dt KSSG 1
+.Os
+.Sh NAME
+.Nm kssg
+.Nd KISS static site generator
+.Sh SYNOPSIS
+.Nm kssg
+.Cm build
+.Nm kssg
+.Cm serve
+.Nm kssg
+.Cm new
+.Op Ar title
+.Sh DESCRIPTION
+.Nm
+is a minimal static site generator written in Rust.
+It converts Markdown files with YAML frontmatter into a static HTML site.
+The generated pages contain no JavaScript and work in text browsers
+such as
+.Xr lynx 1 .
+.Pp
+The commands are as follows:
+.Bl -tag -width Ds
+.It Cm build
+Build the site.
+Markdown files in
+.Pa content/
+are converted to HTML using the template in
+.Pa templates/base.html .
+Static assets from
+.Pa static/
+are copied to
+.Pa public/static/ .
+Files from
+.Pa root/
+are copied to
+.Pa public/ .
+Posts with
+.Cm draft: true
+in the frontmatter are excluded.
+.It Cm serve
+Build the site and start an HTTP server on
+.Li 127.0.0.1:8080 .
+File changes in
+.Pa content/ ,
+.Pa templates/ ,
+.Pa static/ ,
+and
+.Pa root/
+trigger automatic rebuilds.
+The browser is notified via Server-Sent Events (SSE).
+Draft posts are included when serving.
+.It Cm new Op Ar title
+Create a new post in
+.Pa content/posts/ .
+If
+.Ar title
+is given, it is used as the post title.
+Otherwise,
+.Nm
+prompts for a title interactively.
+The post is created with
+.Cm draft: true
+and the current date.
+.El
+.Sh FRONTMATTER
+Each Markdown file must begin with YAML frontmatter enclosed by
+.Ql ---
+delimiters:
+.Bd -literal -offset indent
+---
+title: Post title
+date: 2026-04-07
+updated: 2026-04-08
+description: Short description
+draft: false
+series: series-name
+part: 1
+---
+.Ed
+.Pp
+The fields are as follows:
+.Bl -tag -width "description"
+.It Cm title
+Page title.
+.It Cm date
+Publication date in YYYY-MM-DD format.
+.It Cm updated
+Last modification date.
+Optional.
+.It Cm description
+Meta description for HTML head.
+Optional.
+.It Cm draft
+If
+.Cm true ,
+the page is excluded from
+.Cm build
+but included in
+.Cm serve .
+.It Cm series
+Series identifier for grouping related posts.
+Optional.
+.It Cm part
+Part number within a series.
+Enables prev/next navigation.
+Optional.
+.El
+.Sh ENVIRONMENT
+.Bl -tag -width "KSSG_LOG"
+.It Ev KSSG_LOG
+When set, enable logging to standard error with UTC timestamps.
+.El
+.Sh FILES
+.Bl -tag -width "templates/base.html"
+.It Pa content/
+Markdown source files.
+Subdirectories are preserved in the output.
+.It Pa content/posts/
+Blog posts.
+Listed on the index page grouped by year.
+.It Pa templates/base.html
+HTML template.
+Variables:
+.Cm {{title}} ,
+.Cm {{description}} ,
+.Cm {{content}} ,
+.Cm {{posts}} .
+.It Pa templates/404.html
+Custom 404 page.
+Copied to
+.Pa public/404.html .
+.It Pa static/
+Static assets, copied to
+.Pa public/static/ .
+.It Pa root/
+Files copied to the root of
+.Pa public/
+.Pq e.g. robots.txt, favicon.ico .
+.It Pa public/
+Generated output directory.
+Cleaned before each build.
+.It Pa public/rss.xml
+RSS feed with the last 20 posts.
+.It Pa public/sitemap.xml
+XML sitemap.
+.El
+.Sh EXIT STATUS
+.Ex -std kssg
+.Sh EXAMPLES
+Build the site:
+.Bd -literal -offset indent
+$ kssg build
+.Ed
+.Pp
+Start the development server:
+.Bd -literal -offset indent
+$ kssg serve
+.Ed
+.Pp
+Create a new post:
+.Bd -literal -offset indent
+$ kssg new "TIL: Something I learned"
+.Ed
+.Pp
+Build with logging enabled:
+.Bd -literal -offset indent
+$ KSSG_LOG=1 kssg build
+.Ed
+.Sh SEE ALSO
+.Xr mandoc 1 ,
+.Xr httpd.conf 5 ,
+.Xr httpd 8
+.Sh AUTHORS
+.An Murilo Ijanc' Aq Mt murilo@ijanc.org
blob - a159322123079c6de86d8454a02d7537a9d2bfb1
blob + cec7e5ae7bceb701441d4ea4de4151a3aafed890
--- README.md
+++ README.md
@@ -1,7 +1,75 @@
-# kssg
+kssg - KISS static site generator
+==================================
+kssg is a minimal static site generator written in Rust.
 
-KISS site static generator
 
-## License
+Requirements
+------------
+In order to build kssg you need rustc.
 
-ISC — see [LICENSE](LICENSE).
+
+Installation
+------------
+Edit Makefile to match your local setup (kssg is installed into
+the /usr/local/bin namespace by default).
+
+Afterwards enter the following command to build and install kssg:
+
+    make clean install
+
+To fetch vendored dependencies from crates.io:
+
+    make vendor
+
+
+Running kssg
+------------
+Build your site:
+
+    kssg build
+
+Start the development server with live reload (drafts are visible):
+
+    kssg serve
+
+Create a new post:
+
+    kssg new "Post title"
+
+
+Configuration
+-------------
+The configuration of kssg is done by editing the template
+in templates/base.html and (re)building the site. There is no
+configuration file.
+
+Content lives in content/ as Markdown files with YAML frontmatter:
+
+    ---
+    title: Post title
+    date: 2026-04-07
+    description: Short description
+    draft: false
+    ---
+
+    # Content here
+
+
+Directory structure
+-------------------
+    content/       Markdown source files
+    templates/     HTML templates
+    static/        Static assets (copied to public/static/)
+    root/          Files copied to the root of public/
+    public/        Generated output
+    vendor/        Vendored Rust dependencies
+
+
+Download
+--------
+    got clone ssh://anon@ijanc.org/kssg
+    git clone https://git.ijanc.org/kssg.git
+
+License
+-------
+ISC — see LICENSE.
blob - /dev/null
blob + 0dba79c503e16852493e25c6afb67f1144a42bfd (mode 644)
--- /dev/null
+++ kssg.rs
@@ -0,0 +1,970 @@
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2025-2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+use std::{
+    collections::HashMap,
+    env, fs,
+    io::{self, BufRead, Write as _},
+    net::{TcpListener, TcpStream},
+    path::{Path, PathBuf},
+    sync::{
+        atomic::{AtomicBool, AtomicU64, Ordering},
+        OnceLock,
+    },
+};
+
+fn main() {
+    LOG_ENABLED.set(env::var("KSSG_LOG").is_ok()).ok();
+
+    let args: Vec<String> = env::args().collect();
+
+    match args.get(1).map(|s| s.as_str()) {
+        Some("build") => build(),
+        Some("serve") => {
+            INCLUDE_DRAFTS.store(true, Ordering::Relaxed);
+            serve();
+        }
+        Some("new") => new_post(args.get(2).map(|s| s.as_str())),
+        _ => eprintln!("usage: kssg <build|serve|new [title]>"),
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Log
+//////////////////////////////////////////////////////////////////////////////
+
+static LOG_ENABLED: OnceLock<bool> = OnceLock::new();
+static BUILD_GEN: AtomicU64 = AtomicU64::new(0);
+static INCLUDE_DRAFTS: AtomicBool = AtomicBool::new(false);
+
+macro_rules! log {
+    ($($arg:tt)*) => {
+        if *LOG_ENABLED.get().unwrap_or(&false) {
+            eprintln!("[{}] {}", now_utc(), format_args!($($arg)*));
+        }
+    };
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Server
+//////////////////////////////////////////////////////////////////////////////
+
+fn content_type(path: &Path) -> &'static str {
+    match path.extension().and_then(|e| e.to_str()) {
+        Some("html") => "text/html; charset=utf-8",
+        Some("css") => "text/css",
+        Some("xml") => "application/xml",
+        Some("svg") => "image/svg+xml",
+        Some("ico") => "image/x-icon",
+        Some("txt" | "asc") => "text/plain",
+        _ => "application/octet-stream",
+    }
+}
+
+fn serve() {
+    build();
+
+    std::thread::spawn(|| {
+        watch_and_rebuild();
+    });
+
+    let addr = "127.0.0.1:8080";
+    let listener = TcpListener::bind(addr).expect("bind");
+    println!("serving at http://{}", addr);
+
+    for stream in listener.incoming() {
+        let stream = match stream {
+            Ok(s) => s,
+            Err(_) => continue,
+        };
+        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};
+
+    let reader = BufReader::new(&stream);
+    let request_line = match reader.lines().next() {
+        Some(Ok(line)) => line,
+        _ => return,
+    };
+
+    log!("{}", request_line);
+
+    // "GET /path HTTP/1.1"
+    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..]);
+
+    if file_path.is_dir() {
+        file_path.push("index.html");
+    }
+    if !file_path.exists()
+        && !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 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 = 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!(
+        "HTTP/1.1 {}\r\nContent-Type: {}\r\n\
+        Content-Length: {}\r\nConnection: close\r\n\r\n",
+        status,
+        ctype,
+        body.len()
+    );
+
+    let _ = stream.write_all(response.as_bytes());
+    let _ = stream.write_all(&body);
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// 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
+//////////////////////////////////////////////////////////////////////////////
+
+#[repr(C)]
+struct Tm {
+    tm_sec: i32,
+    tm_min: i32,
+    tm_hour: i32,
+    tm_mday: i32,
+    tm_mon: i32,
+    tm_year: i32,
+    tm_wday: i32,
+    tm_yday: i32,
+    tm_isdst: i32,
+}
+
+unsafe extern "C" {
+    fn time(t: *mut i64) -> i64;
+    fn gmtime(t: *const i64) -> *const Tm;
+}
+
+fn now_utc() -> String {
+    unsafe {
+        let mut t: i64 = 0;
+        time(&mut t);
+        let tm = &*gmtime(&t);
+        format!(
+            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
+            tm.tm_year + 1900,
+            tm.tm_mon + 1,
+            tm.tm_mday,
+            tm.tm_hour,
+            tm.tm_min,
+            tm.tm_sec,
+        )
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Frontmatter
+//////////////////////////////////////////////////////////////////////////////
+
+struct Metadata {
+    title: String,
+    date: String,
+    updated: String,
+    description: String,
+    draft: bool,
+    series: Option<String>,
+    part: Option<u32>,
+}
+
+fn parse_options() -> markdown::ParseOptions {
+    markdown::ParseOptions {
+        constructs: markdown::Constructs {
+            frontmatter: true,
+            ..markdown::Constructs::default()
+        },
+        ..markdown::ParseOptions::default()
+    }
+}
+
+fn parse_frontmatter(content: &str) -> Metadata {
+    let tree =
+        markdown::to_mdast(content, &parse_options()).expect("parse mdast");
+    let mut map = HashMap::new();
+
+    if let markdown::mdast::Node::Root(root) = tree {
+        for node in &root.children {
+            if let markdown::mdast::Node::Yaml(yaml) = node {
+                for line in yaml.value.lines() {
+                    if let Some((k, v)) = line.split_once(':') {
+                        let val = v.trim();
+                        let val = val
+                            .strip_prefix('"')
+                            .and_then(|s| s.strip_suffix('"'))
+                            .unwrap_or(val);
+                        map.insert(
+                            k.trim().to_string(),
+                            val.to_string(),
+                        );
+                    }
+                }
+            }
+        }
+    }
+
+    Metadata {
+        title: map.remove("title").unwrap_or_default(),
+        date: map.remove("date").unwrap_or_default(),
+        updated: map.remove("updated").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()),
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Filesystem
+//////////////////////////////////////////////////////////////////////////////
+
+fn walk_dirs(dir: &Path, dirs: &mut Vec<PathBuf>) {
+    dirs.push(dir.to_path_buf());
+    let entries = match fs::read_dir(dir) {
+        Ok(e) => e,
+        Err(_) => return,
+    };
+    for entry in entries {
+        let path = match entry {
+            Ok(e) => e.path(),
+            Err(_) => continue,
+        };
+        if path.is_dir() {
+            walk_dirs(&path, dirs);
+        }
+    }
+}
+
+fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
+    let entries = match fs::read_dir(dir) {
+        Ok(e) => e,
+        Err(_) => return,
+    };
+
+    for entry in entries {
+        let path = match entry {
+            Ok(e) => e.path(),
+            Err(_) => continue,
+        };
+
+        if path.is_dir() {
+            walk(&path, files);
+        } else {
+            files.push(path);
+        }
+    }
+}
+
+fn copy_dir(src: &Path, dst: &Path) {
+    let mut files = Vec::new();
+    walk(src, &mut files);
+    for file in files {
+        let relative = file.strip_prefix(src).expect("strip prefix");
+        let target = dst.join(relative);
+        if let Some(parent) = target.parent() {
+            fs::create_dir_all(parent).expect("create dir");
+        }
+        fs::copy(file, &target).expect("copy file");
+    }
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// New Post
+//////////////////////////////////////////////////////////////////////////////
+
+fn slugify(s: &str) -> String {
+    s.to_lowercase()
+        .chars()
+        .map(|c| match c {
+            'a'..='z' | '0'..='9' => c,
+            'á' | 'à' | 'ã' | 'â' | 'ä' => 'a',
+            'é' | 'è' | 'ê' | 'ë' => 'e',
+            'í' | 'ì' | 'î' | 'ï' => 'i',
+            'ó' | 'ò' | 'õ' | 'ô' | 'ö' => 'o',
+            'ú' | 'ù' | 'û' | 'ü' => 'u',
+            'ç' => 'c',
+            _ => '-',
+        })
+        .collect::<String>()
+        .split('-')
+        .filter(|s| !s.is_empty())
+        .collect::<Vec<_>>()
+        .join("-")
+}
+
+fn today() -> String {
+    unsafe {
+        let mut t: i64 = 0;
+        time(&mut t);
+        let tm = &*gmtime(&t);
+        format!(
+            "{:04}-{:02}-{:02}",
+            tm.tm_year + 1900,
+            tm.tm_mon + 1,
+            tm.tm_mday,
+        )
+    }
+}
+
+fn new_post(arg_title: Option<&str>) {
+    let title = match arg_title {
+        Some(t) => t.to_string(),
+        None => {
+            print!("titulo: ");
+            io::stdout().flush().ok();
+            let mut line = String::new();
+            io::stdin().lock().read_line(&mut line).expect("read stdin");
+            let line = line.trim().to_string();
+            if line.is_empty() {
+                "novo post".to_string()
+            } else {
+                line
+            }
+        }
+    };
+
+    let date = today();
+    let slug = slugify(&title);
+    let path = format!("content/posts/{}.md", slug);
+
+    if Path::new(&path).exists() {
+        eprintln!("já existe: {}", path);
+        return;
+    }
+
+    fs::create_dir_all("content/posts").expect("create posts dir");
+
+    let content = format!(
+        "---\n\
+         title: {}\n\
+         date: {}\n\
+         description: \n\
+         draft: true\n\
+         ---\n\n\
+         # {}\n",
+        title, date, title,
+    );
+
+    fs::write(&path, content).expect("write post");
+    println!("{}", path);
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Build
+//////////////////////////////////////////////////////////////////////////////
+
+struct Post {
+    meta: Metadata,
+    html_content: String,
+    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 content_dir = Path::new("content");
+    let mut files = Vec::new();
+    walk(content_dir, &mut files);
+
+    let mut posts: Vec<Post> = Vec::new();
+
+    for file in &files {
+        if file.extension().and_then(|e| e.to_str()) != Some("md") {
+            continue;
+        }
+
+        let md = fs::read_to_string(file).expect("read content");
+        let meta = parse_frontmatter(&md);
+
+        if meta.draft && !INCLUDE_DRAFTS.load(Ordering::Relaxed) {
+            log!("skip draft: {}", file.display());
+            continue;
+        }
+
+        let html_content = markdown::to_html_with_options(
+            &md,
+            &markdown::Options {
+                parse: parse_options(),
+                ..markdown::Options::default()
+            },
+        )
+        .expect("markdown to html");
+
+        let relative = file.strip_prefix(content_dir).expect("strip prefix");
+        let out_path =
+            Path::new("public").join(relative).with_extension("html");
+
+        posts.push(Post {
+            meta,
+            html_content,
+            out_path,
+        });
+    }
+
+    let post_list = generate_post_list(&posts);
+
+    for i in 0..posts.len() {
+        let nav = series_nav(&posts, i);
+
+        let mut content = String::new();
+
+        if posts[i].out_path.starts_with("public/posts") {
+            content.push_str("<p class=\"dates\">");
+            content.push_str(&format!(
+                "<time>Criado: {}</time>",
+                posts[i].meta.date,
+            ));
+            if !posts[i].meta.updated.is_empty() {
+                content.push_str(&format!(
+                    " | <time>Atualizado: {}</time>",
+                    posts[i].meta.updated,
+                ));
+            }
+            content.push_str("</p>\n");
+        }
+
+        content.push_str(&posts[i].html_content);
+        if !nav.is_empty() {
+            content.push_str(&nav);
+        }
+
+        let list = if posts[i].out_path == Path::new("public/index.html") {
+            &post_list
+        } else {
+            ""
+        };
+
+        let output = template
+            .replace("{{title}}", &posts[i].meta.title)
+            .replace("{{description}}", &posts[i].meta.description)
+            .replace("{{content}}", &content)
+            .replace("{{posts}}", list);
+
+        if let Some(parent) = posts[i].out_path.parent() {
+            fs::create_dir_all(parent).expect("create dir");
+        }
+
+        fs::write(&posts[i].out_path, output).expect("write html");
+        log!("wrote {}", posts[i].out_path.display());
+    }
+
+    let static_dir = Path::new("static");
+    if static_dir.exists() {
+        copy_dir(static_dir, Path::new("public/static"));
+    }
+
+    let root_dir = Path::new("root");
+    if root_dir.exists() {
+        copy_dir(root_dir, Path::new("public"));
+    }
+
+    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.starts_with("public/posts"))
+        .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,
+        None => return String::new(),
+    };
+
+    let cur_part = posts[current].meta.part.unwrap_or(0);
+
+    let mut prev: Option<&Post> = None;
+    let mut next: Option<&Post> = None;
+
+    for post in posts {
+        if post.meta.series.as_deref() != Some(series) {
+            continue;
+        }
+
+        let p = post.meta.part.unwrap_or(0);
+        if p + 1 == cur_part {
+            prev = Some(post);
+        }
+        if p == cur_part + 1 {
+            next = Some(post);
+        }
+    }
+
+    if prev.is_none() && next.is_none() {
+        return String::new();
+    }
+
+    let mut nav = String::from("<nav class=\"series-nav\">");
+
+    if let Some(p) = prev {
+        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.strip_prefix("public").unwrap_or(&p.out_path).display(),
+        );
+        nav.push_str(&format!(
+            "<a href=\"{}\">{} \u{2192}</a>",
+            href, p.meta.title
+        ));
+    }
+
+    nav.push_str("</nav>");
+    nav
+}
+
+fn generate_post_list(posts: &[Post]) -> String {
+    let mut items: Vec<(&str, &str, &Path)> = Vec::new();
+
+    for post in posts {
+        if !post.out_path.starts_with("public/posts") {
+            continue;
+        }
+        items.push((&post.meta.date, &post.meta.title, &post.out_path));
+    }
+
+    items.sort_by(|a, b| b.0.cmp(a.0));
+
+    let mut html = String::new();
+    let mut current_year = "";
+
+    for (date, title, path) in &items {
+        let year = &date[..4];
+        if year != current_year {
+            if !current_year.is_empty() {
+                html.push_str("</ul>\n");
+            }
+            html.push_str(&format!("<h2>{}</h2>\n<ul>\n", year));
+            current_year = year;
+        }
+        let href = format!(
+            "/{}",
+            path.strip_prefix("public").unwrap_or(path).display()
+        );
+        html.push_str(&format!(
+            "<li><time>{}</time> - <a href=\"{}\">{}</a></li>\n",
+            date, href, title
+        ));
+    }
+
+    if !current_year.is_empty() {
+        html.push_str("</ul>\n");
+    }
+
+    html
+}
+
+//////////////////////////////////////////////////////////////////////////////
+// Watcher
+//////////////////////////////////////////////////////////////////////////////
+
+fn watch_paths() -> Vec<PathBuf> {
+    let mut dirs = Vec::new();
+    for base in &["content", "templates", "static", "root"] {
+        let path = Path::new(base);
+        if path.exists() {
+            walk_dirs(path, &mut dirs);
+        }
+    }
+    dirs
+}
+
+#[cfg(target_os = "linux")]
+fn watch_and_rebuild() {
+    unsafe extern "C" {
+        fn inotify_init() -> i32;
+        fn inotify_add_watch(
+            fd: i32, path: *const i8, mask: u32,
+        ) -> i32;
+        fn read(fd: i32, buf: *mut u8, count: usize) -> isize;
+    }
+
+    const IN_MODIFY: u32 = 0x02;
+    const IN_CREATE: u32 = 0x100;
+    const IN_DELETE: u32 = 0x200;
+    const MASK: u32 = IN_MODIFY | IN_CREATE | IN_DELETE;
+
+    let fd = unsafe { inotify_init() };
+    if fd < 0 {
+        eprintln!("inotify_init failed");
+        return;
+    }
+
+    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)
+            };
+            if wd < 0 {
+                eprintln!("watch failed: {}", dir.display());
+            } else {
+                log!("watching {}", dir.display());
+            }
+        }
+    }
+
+    add_watches(fd, MASK);
+    log!("watcher ready");
+
+    let mut buf = [0u8; 4096];
+    loop {
+        let n = unsafe {
+            read(fd, buf.as_mut_ptr(), buf.len())
+        };
+        if n <= 0 {
+            continue;
+        }
+        log!("change detected, rebuilding");
+        build();
+        add_watches(fd, MASK);
+    }
+}
+
+#[cfg(target_os = "openbsd")]
+fn watch_and_rebuild() {
+    use std::ffi::CString;
+
+    unsafe extern "C" {
+        fn kqueue() -> i32;
+        fn open(path: *const i8, flags: i32) -> i32;
+        fn kevent(
+            kq: i32,
+            changelist: *const KEvent,
+            nchanges: i32,
+            eventlist: *mut KEvent,
+            nevents: i32,
+            timeout: *const Timespec,
+        ) -> i32;
+    }
+
+    #[repr(C)]
+    struct KEvent {
+        ident: usize,
+        filter: i16,
+        flags: u16,
+        fflags: u32,
+        data: isize,
+        udata: *mut u8,
+    }
+
+    #[repr(C)]
+    struct Timespec {
+        tv_sec: i64,
+        tv_nsec: i64,
+    }
+
+    const EVFILT_VNODE: i16 = -4;
+    const EV_ADD: u16 = 0x0001;
+    const EV_CLEAR: u16 = 0x0020;
+    const NOTE_WRITE: u32 = 0x0002;
+    const NOTE_DELETE: u32 = 0x0001;
+    const NOTE_RENAME: u32 = 0x0020;
+    const O_RDONLY: i32 = 0;
+    const FFLAGS: u32 =
+        NOTE_WRITE | NOTE_DELETE | NOTE_RENAME;
+
+    let kq = unsafe { kqueue() };
+    if kq < 0 {
+        eprintln!("kqueue failed");
+        return;
+    }
+
+    let mut watched: Vec<PathBuf> = Vec::new();
+
+    fn register_new_dirs(
+        kq: i32,
+        watched: &mut Vec<PathBuf>,
+    ) {
+        use std::ffi::CString;
+
+        unsafe extern "C" {
+            fn open(path: *const i8, flags: i32) -> i32;
+            fn kevent(
+                kq: i32,
+                changelist: *const KEvent,
+                nchanges: i32,
+                eventlist: *mut KEvent,
+                nevents: i32,
+                timeout: *const Timespec,
+            ) -> i32;
+        }
+
+        const EVFILT_VNODE: i16 = -4;
+        const EV_ADD: u16 = 0x0001;
+        const EV_CLEAR: u16 = 0x0020;
+        const NOTE_WRITE: u32 = 0x0002;
+        const NOTE_DELETE: u32 = 0x0001;
+        const NOTE_RENAME: u32 = 0x0020;
+        const O_RDONLY: i32 = 0;
+        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 fd = unsafe { open(cstr.as_ptr(), O_RDONLY) };
+            if fd < 0 {
+                eprintln!("open failed: {}", dir.display());
+                continue;
+            }
+            let ev = KEvent {
+                ident: fd as usize,
+                filter: EVFILT_VNODE,
+                flags: EV_ADD | EV_CLEAR,
+                fflags: FFLAGS,
+                data: 0,
+                udata: std::ptr::null_mut(),
+            };
+            unsafe {
+                kevent(
+                    kq, &ev, 1,
+                    std::ptr::null_mut(), 0,
+                    std::ptr::null(),
+                );
+            }
+            log!("watching {}", dir.display());
+            watched.push(dir);
+        }
+    }
+
+    register_new_dirs(kq, &mut watched);
+    log!("watcher ready");
+
+    let mut event = KEvent {
+        ident: 0,
+        filter: 0,
+        flags: 0,
+        fflags: 0,
+        data: 0,
+        udata: std::ptr::null_mut(),
+    };
+
+    loop {
+        let n = unsafe {
+            kevent(
+                kq,
+                std::ptr::null(),
+                0,
+                &mut event,
+                1,
+                std::ptr::null(),
+            )
+        };
+        if n <= 0 {
+            continue;
+        }
+        log!("change detected, rebuilding");
+        build();
+        register_new_dirs(kq, &mut watched);
+    }
+}
blob - 00202058e5e7989f735f6e362d18f249e550ee6f (mode 644)
blob + /dev/null
--- content/index.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-title: Hello kssg
-date: 2026-04-03
-draft: false
----
-
-# Bem-Vindo
-
-Esse é o **primeiro post** gerado pelo kssg.
blob - b5d009f9cb11e4d0e9c74c4edf7e6535e8546ddf (mode 644)
blob + /dev/null
--- content/posts/os-part1.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-title: Construindo um OS em Rust - Parte 1
-date: 2026-04-01
-series: os-em-rust
-part: 1
----
-
-# O Boot
-
-O que acontece quando você liga o computador...
blob - 1a9bbef4b535816f532fe21028d2db8fac059b7e (mode 644)
blob + /dev/null
--- content/posts/os-part2.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-title: Construindo um OS em Rust - Parte 2
-date: 2026-04-02
-series: os-em-rust
-part: 2
----
-
-# Modo Protegido
-
-Escapando do modo real...
blob - b8dfd1ffaef4e27e6c910f63979c5e7a6464229d (mode 644)
blob + /dev/null
--- content/posts/os-part3.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-title: Construindo um OS em Rust - Parte 3
-date: 2026-04-03
-series: os-em-rust
-part: 3
----
-
-# Longo Mode
-
-Ativando 64-bit
-
blob - 8dc66ae98fe34a3fdf6c8c299e198bf4e3637933 (mode 644)
blob + /dev/null
--- content/posts/primeiro-post.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-title: Primeiro Post
-date: 2026-04-03
-draft: false
----
-
-# Meu primeiro post
-
-Conteúdo aqui.......
blob - 4a3f35186e4da66bf133cb2ed4530ada077fa057 (mode 644)
blob + /dev/null
--- src/main.rs
+++ /dev/null
@@ -1,808 +0,0 @@
-// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
-//
-// Copyright (c) 2025-2026 Murilo Ijanc' <murilo@ijanc.org>
-//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
-//
-// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
-// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
-// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
-// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
-//
-
-use std::{
-    collections::HashMap,
-    env, fs,
-    net::{TcpListener, TcpStream},
-    path::{Path, PathBuf},
-    sync::{
-        atomic::{AtomicU64, Ordering},
-        OnceLock,
-    },
-};
-
-fn main() {
-    LOG_ENABLED.set(env::var("KSSG_LOG").is_ok()).ok();
-
-    let args: Vec<String> = env::args().collect();
-
-    match args.get(1).map(|s| s.as_str()) {
-        Some("build") => build(),
-        Some("serve") => serve(),
-        _ => eprintln!("usage: ksgg <build|serve>"),
-    }
-}
-
-//////////////////////////////////////////////////////////////////////////////
-// Log
-//////////////////////////////////////////////////////////////////////////////
-
-static LOG_ENABLED: OnceLock<bool> = OnceLock::new();
-static BUILD_GEN: AtomicU64 = AtomicU64::new(0);
-
-macro_rules! log {
-    ($($arg:tt)*) => {
-        if *LOG_ENABLED.get().unwrap_or(&false) {
-            eprintln!("[{}] {}", now_utc(), format_args!($($arg)*));
-        }
-    };
-}
-
-//////////////////////////////////////////////////////////////////////////////
-// Server
-//////////////////////////////////////////////////////////////////////////////
-
-fn content_type(path: &Path) -> &'static str {
-    match path.extension().and_then(|e| e.to_str()) {
-        Some("html") => "text/html; charset=utf-8",
-        Some("css") => "text/css",
-        _ => "application/octet-stream",
-    }
-}
-
-fn serve() {
-    build();
-
-    std::thread::spawn(|| {
-        watch_and_rebuild();
-    });
-
-    let addr = "127.0.0.1:8080";
-    let listener = TcpListener::bind(addr).expect("bind");
-    println!("serving at http://{}", addr);
-
-    for stream in listener.incoming() {
-        let stream = match stream {
-            Ok(s) => s,
-            Err(_) => continue,
-        };
-        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};
-
-    let reader = BufReader::new(&stream);
-    let request_line = match reader.lines().next() {
-        Some(Ok(line)) => line,
-        _ => return,
-    };
-
-    log!("{}", request_line);
-
-    // "GET /path HTTP/1.1"
-    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..]);
-
-    if file_path.is_dir() {
-        file_path.push("index.html");
-    }
-    if !file_path.exists()
-        && !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 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 = 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!(
-        "HTTP/1.1 {}\r\nContent-Type: {}\r\n\
-        Content-Length: {}\r\nConnection: close\r\n\r\n",
-        status,
-        ctype,
-        body.len()
-    );
-
-    let _ = stream.write_all(response.as_bytes());
-    let _ = stream.write_all(&body);
-}
-
-//////////////////////////////////////////////////////////////////////////////
-// 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
-//////////////////////////////////////////////////////////////////////////////
-
-#[repr(C)]
-struct Tm {
-    tm_sec: i32,
-    tm_min: i32,
-    tm_hour: i32,
-    tm_mday: i32,
-    tm_mon: i32,
-    tm_year: i32,
-    tm_wday: i32,
-    tm_yday: i32,
-    tm_isdst: i32,
-}
-
-unsafe extern "C" {
-    fn time(t: *mut i64) -> i64;
-    fn gmtime(t: *const i64) -> *const Tm;
-}
-
-fn now_utc() -> String {
-    unsafe {
-        let mut t: i64 = 0;
-        time(&mut t);
-        let tm = &*gmtime(&t);
-        format!(
-            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
-            tm.tm_year + 1900,
-            tm.tm_mon + 1,
-            tm.tm_mday,
-            tm.tm_hour,
-            tm.tm_min,
-            tm.tm_sec,
-        )
-    }
-}
-
-//////////////////////////////////////////////////////////////////////////////
-// Frontmatter
-//////////////////////////////////////////////////////////////////////////////
-
-struct Metadata {
-    title: String,
-    date: String,
-    description: String,
-    draft: bool,
-    series: Option<String>,
-    part: Option<u32>,
-}
-
-fn parse_options() -> markdown::ParseOptions {
-    markdown::ParseOptions {
-        constructs: markdown::Constructs {
-            frontmatter: true,
-            ..markdown::Constructs::default()
-        },
-        ..markdown::ParseOptions::default()
-    }
-}
-
-fn parse_frontmatter(content: &str) -> Metadata {
-    let tree =
-        markdown::to_mdast(content, &parse_options()).expect("parse mdast");
-    let mut map = HashMap::new();
-
-    if let markdown::mdast::Node::Root(root) = tree {
-        for node in &root.children {
-            if let markdown::mdast::Node::Yaml(yaml) = node {
-                for line in yaml.value.lines() {
-                    if let Some((k, v)) = line.split_once(':') {
-                        map.insert(k.trim().to_string(), v.trim().to_string());
-                    }
-                }
-            }
-        }
-    }
-
-    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()),
-    }
-}
-
-//////////////////////////////////////////////////////////////////////////////
-// Filesystem
-//////////////////////////////////////////////////////////////////////////////
-
-fn walk_dirs(dir: &Path, dirs: &mut Vec<PathBuf>) {
-    dirs.push(dir.to_path_buf());
-    let entries = match fs::read_dir(dir) {
-        Ok(e) => e,
-        Err(_) => return,
-    };
-    for entry in entries {
-        let path = match entry {
-            Ok(e) => e.path(),
-            Err(_) => continue,
-        };
-        if path.is_dir() {
-            walk_dirs(&path, dirs);
-        }
-    }
-}
-
-fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
-    let entries = match fs::read_dir(dir) {
-        Ok(e) => e,
-        Err(_) => return,
-    };
-
-    for entry in entries {
-        let path = match entry {
-            Ok(e) => e.path(),
-            Err(_) => continue,
-        };
-
-        if path.is_dir() {
-            walk(&path, files);
-        } else {
-            files.push(path);
-        }
-    }
-}
-
-fn copy_dir(src: &Path, dst: &Path) {
-    let mut files = Vec::new();
-    walk(src, &mut files);
-    for file in files {
-        let relative = file.strip_prefix(src).expect("strip prefix");
-        let target = dst.join(relative);
-        if let Some(parent) = target.parent() {
-            fs::create_dir_all(parent).expect("create dir");
-        }
-        fs::copy(file, &target).expect("copy file");
-    }
-}
-
-//////////////////////////////////////////////////////////////////////////////
-// Build
-//////////////////////////////////////////////////////////////////////////////
-
-struct Post {
-    meta: Metadata,
-    html_content: String,
-    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 content_dir = Path::new("content");
-    let mut files = Vec::new();
-    walk(content_dir, &mut files);
-
-    let mut posts: Vec<Post> = Vec::new();
-
-    for file in &files {
-        if file.extension().and_then(|e| e.to_str()) != Some("md") {
-            continue;
-        }
-
-        let md = fs::read_to_string(file).expect("read content");
-        let meta = parse_frontmatter(&md);
-
-        if meta.draft {
-            log!("skip draft: {}", file.display());
-            continue;
-        }
-
-        let html_content = markdown::to_html_with_options(
-            &md,
-            &markdown::Options {
-                parse: parse_options(),
-                ..markdown::Options::default()
-            },
-        )
-        .expect("markdown to html");
-
-        let relative = file.strip_prefix(content_dir).expect("strip prefix");
-        let out_path =
-            Path::new("public").join(relative).with_extension("html");
-
-        posts.push(Post {
-            meta,
-            html_content,
-            out_path,
-        });
-    }
-
-    let post_list = generate_post_list(&posts);
-
-    for i in 0..posts.len() {
-        let nav = series_nav(&posts, i);
-
-        let mut content = posts[i].html_content.clone();
-        if !nav.is_empty() {
-            content.push_str(&nav);
-        }
-
-        let list = if posts[i].out_path == Path::new("public/index.html") {
-            &post_list
-        } else {
-            ""
-        };
-
-        let output = template
-            .replace("{{title}}", &posts[i].meta.title)
-            .replace("{{description}}", &posts[i].meta.description)
-            .replace("{{content}}", &content)
-            .replace("{{posts}}", list);
-
-        if let Some(parent) = posts[i].out_path.parent() {
-            fs::create_dir_all(parent).expect("create dir");
-        }
-
-        fs::write(&posts[i].out_path, output).expect("write html");
-        log!("wrote {}", posts[i].out_path.display());
-    }
-
-    let static_dir = Path::new("static");
-    if static_dir.exists() {
-        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,
-        None => return String::new(),
-    };
-
-    let cur_part = posts[current].meta.part.unwrap_or(0);
-
-    let mut prev: Option<&Post> = None;
-    let mut next: Option<&Post> = None;
-
-    for post in posts {
-        if post.meta.series.as_deref() != Some(series) {
-            continue;
-        }
-
-        let p = post.meta.part.unwrap_or(0);
-        if p + 1 == cur_part {
-            prev = Some(post);
-        }
-        if p == cur_part + 1 {
-            next = Some(post);
-        }
-    }
-
-    if prev.is_none() && next.is_none() {
-        return String::new();
-    }
-
-    let mut nav = String::from("<nav class=\"series-nav\">");
-
-    if let Some(p) = prev {
-        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.strip_prefix("public").unwrap_or(&p.out_path).display(),
-        );
-        nav.push_str(&format!(
-            "<a href=\"{}\">{} \u{2192}</a>",
-            href, p.meta.title
-        ));
-    }
-
-    nav.push_str("</nav>");
-    nav
-}
-
-fn generate_post_list(posts: &[Post]) -> String {
-    let mut items: Vec<(&str, &str, &Path)> = Vec::new();
-
-    for post in posts {
-        if post.out_path == Path::new("public/index.html") {
-            continue;
-        }
-        items.push((&post.meta.date, &post.meta.title, &post.out_path));
-    }
-
-    items.sort_by(|a, b| b.0.cmp(a.0));
-
-    let mut html = String::from("<ul class=\"post-list\">\n");
-    for (date, title, path) in &items {
-        let href = format!(
-            "/{}",
-            path.strip_prefix("public").unwrap_or(path).display()
-        );
-        html.push_str(&format!(
-            "<li><time>{}</time> - <a href=\"{}\">{}</a></li>\n",
-            date, href, title
-        ));
-    }
-
-    html.push_str("</ul>");
-    html
-}
-
-//////////////////////////////////////////////////////////////////////////////
-// Watcher
-//////////////////////////////////////////////////////////////////////////////
-
-fn watch_paths() -> Vec<PathBuf> {
-    let mut dirs = Vec::new();
-    for base in &["content", "templates", "static"] {
-        let path = Path::new(base);
-        if path.exists() {
-            walk_dirs(path, &mut dirs);
-        }
-    }
-    dirs
-}
-
-#[cfg(target_os = "linux")]
-fn watch_and_rebuild() {
-    use std::ffi::CString;
-
-    unsafe extern "C" {
-        fn inotify_init() -> i32;
-        fn inotify_add_watch(
-            fd: i32, path: *const i8, mask: u32,
-        ) -> i32;
-        fn read(fd: i32, buf: *mut u8, count: usize) -> isize;
-    }
-
-    const IN_MODIFY: u32 = 0x02;
-    const IN_CREATE: u32 = 0x100;
-    const IN_DELETE: u32 = 0x200;
-    const MASK: u32 = IN_MODIFY | IN_CREATE | IN_DELETE;
-
-    let fd = unsafe { inotify_init() };
-    if fd < 0 {
-        eprintln!("inotify_init failed");
-        return;
-    }
-
-    let dirs = watch_paths();
-    for dir in &dirs {
-        let cstr = 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 {
-            log!("watching {}", dir.display());
-        }
-    }
-
-    log!("watcher ready");
-
-    let mut buf = [0u8; 4096];
-    loop {
-        let n = unsafe {
-            read(fd, buf.as_mut_ptr(), buf.len())
-        };
-        if n <= 0 {
-            continue;
-        }
-        log!("change detected, rebuilding");
-        build();
-    }
-}
-
-#[cfg(target_os = "openbsd")]
-fn watch_and_rebuild() {
-    use std::ffi::CString;
-
-    unsafe extern "C" {
-        fn kqueue() -> i32;
-        fn open(path: *const i8, flags: i32) -> i32;
-        fn kevent(
-            kq: i32,
-            changelist: *const KEvent,
-            nchanges: i32,
-            eventlist: *mut KEvent,
-            nevents: i32,
-            timeout: *const Timespec,
-        ) -> i32;
-    }
-
-    #[repr(C)]
-    struct KEvent {
-        ident: usize,
-        filter: i16,
-        flags: u16,
-        fflags: u32,
-        data: isize,
-        udata: *mut u8,
-    }
-
-    #[repr(C)]
-    struct Timespec {
-        tv_sec: i64,
-        tv_nsec: i64,
-    }
-
-    const EVFILT_VNODE: i16 = -4;
-    const EV_ADD: u16 = 0x0001;
-    const EV_CLEAR: u16 = 0x0020;
-    const NOTE_WRITE: u32 = 0x0002;
-    const NOTE_DELETE: u32 = 0x0001;
-    const NOTE_RENAME: u32 = 0x0020;
-    const O_RDONLY: i32 = 0;
-    const FFLAGS: u32 =
-        NOTE_WRITE | NOTE_DELETE | NOTE_RENAME;
-
-    let kq = unsafe { kqueue() };
-    if kq < 0 {
-        eprintln!("kqueue failed");
-        return;
-    }
-
-    let dirs = watch_paths();
-    let mut fds: Vec<i32> = Vec::new();
-
-    for dir in &dirs {
-        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());
-            continue;
-        }
-        fds.push(fd);
-        log!("watching {}", dir.display());
-    }
-
-    let changes: Vec<KEvent> = fds
-        .iter()
-        .map(|&fd| KEvent {
-            ident: fd as usize,
-            filter: EVFILT_VNODE,
-            flags: EV_ADD | EV_CLEAR,
-            fflags: FFLAGS,
-            data: 0,
-            udata: std::ptr::null_mut(),
-        })
-        .collect();
-
-    unsafe {
-        kevent(
-            kq,
-            changes.as_ptr(),
-            changes.len() as i32,
-            std::ptr::null_mut(),
-            0,
-            std::ptr::null(),
-        );
-    }
-
-    log!("watcher ready");
-
-    let mut event = KEvent {
-        ident: 0,
-        filter: 0,
-        flags: 0,
-        fflags: 0,
-        data: 0,
-        udata: std::ptr::null_mut(),
-    };
-
-    loop {
-        let n = unsafe {
-            kevent(
-                kq,
-                std::ptr::null(),
-                0,
-                &mut event,
-                1,
-                std::ptr::null(),
-            )
-        };
-        if n <= 0 {
-            continue;
-        }
-        log!("change detected, rebuilding");
-        build();
-    }
-}
blob - 18c51f5ba51cae75c667e89bf240331384f536df (mode 644)
blob + /dev/null
--- static/style.css
+++ /dev/null
@@ -1 +0,0 @@
-body { max-width: 40em; margin: 0 auto; }
blob - 39e1901dc54abdb3d1413ce915f7c14eaaaf1ef7
blob + f8414f0ed249bcd37e1e632bd5efb65286cf87ee
--- templates/404.html
+++ templates/404.html
@@ -1,13 +1,3 @@
-<!-- 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>
+<!doctype html>
+<title>404 Not Found</title>
+<h1>404 Not Found</h1>
blob - 9257329ed59fbce876f6fa8e8460cfd5f7602f76
blob + 593f25ef3fc79f620197be89cd09ae262882ccbf
--- templates/base.html
+++ templates/base.html
@@ -1,13 +1,52 @@
 <!-- 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>{{title}}</title>
-    <meta name="description" content="{{description}}">
-  </head>
-  <body>
-    {{content}}
-    {{posts}}
-  </body>
+<!doctype html>
+<html lang="pt">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta name="author" content="murilo ijanc'">
+  <meta name="keywords"
+    content="programação, computadores, linguagem, bsd, música">
+  <meta name="robots" content="noai, noimageai">
+  <meta name="description" content="{{description}}">
+  <link rel="alternate" type="application/atom+xml"
+    title="MuriloIjanc-Feed" href="/rss.xml">
+  <title>{{title}}</title>
+  <link href="/favicon.ico" rel="shortcut icon">
+  <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+<nav>
+  <p>
+    <a href="/">Início</a> |
+    <a href="/about.html">Sobre</a> |
+    <a href="/repos.html">Repos</a> |
+    <a href="/contact.html">Contato</a> |
+    <a href="/rss.xml">RSS
+      <img src="/static/rss.svg" alt="Rss Icon" height="12"></a>
+  </p>
+</nav>
+<main>
+  {{content}}
+  {{posts}}
+</main>
+<footer>
+  <hr>
+  <p class="webrings">
+    <a href="https://webring.xxiivv.com/">
+      <img src="/static/xxiivv-webring.svg" alt="XXIIVV webring"
+        width="26" height="26"></a> |
+    <a href="http://geekring.net/site/NUMBER/previous">&lt;</a>
+    <a href="https://geekring.net/">GeekRing</a>
+    <a href="http://geekring.net/site/NUMBER/next">&gt;</a>
+  </p>
+  <p>
+    Powered by
+    <a href="https://www.openbsd.org">OpenBSD</a>,
+    <a href="https://man.openbsd.org/httpd.8">httpd(8)</a>,
+    <a href="https://man.openbsd.org/relayd.8">relayd(8)</a>, e
+    <a href="https://got.ijanc.org/?action=summary&path=kssg.git">kssg</a>
+  </p>
+</footer>
+</body>
 </html>