commit ca3ca403aa8ffe3108c43b202a7709e3bc043912 from: Murilo Ijanc date: Tue Apr 7 16:12:45 2026 UTC replace cargo with rustc makefile, add manpage, separate site content remove cargo dependency, compile with rustc via makefile and vendored crates. move source from src/main.rs to kssg.rs. add kssg.1 mandoc manpage. remove example content (site content lives in ijanc.org repo now). add new subcommand, updated frontmatter field, draft visibility in serve, post listing by year, root/ directory support, webring footer, content type detection, watcher re-registration for new dirs. 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' "] - -[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' +.\" +.\" 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' +// +// 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 = 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 "), + } +} + +////////////////////////////////////////////////////////////////////////////// +// Log +////////////////////////////////////////////////////////////////////////////// + +static LOG_ENABLED: OnceLock = 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 = ""; + +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("", &format!("{SSE_SCRIPT}")); + 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, + part: Option, +} + +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) { + 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) { + 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::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .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 = 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("

"); + content.push_str(&format!( + "", + posts[i].meta.date, + )); + if !posts[i].meta.updated.is_empty() { + content.push_str(&format!( + " | ", + posts[i].meta.updated, + )); + } + content.push_str("

\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( + "\n\ + \n\n\ + ijanc.org\n\ + https://ijanc.org\n\ + ijanc.org\n", + ); + + for post in items.iter().take(20) { + let href = post + .out_path + .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", + SITE_URL, + href.display(), + )); + xml.push_str(&format!( + "{}\n", + post.meta.description, + )); + xml.push_str(&format!( + "{}\n", + post.meta.date, + )); + xml.push_str("\n"); + } + + xml.push_str("\n\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( + "\n\ + \n", + ); + + // index + xml.push_str(&format!( + "{}/\n", + SITE_URL, + )); + + for post in posts { + let href = post + .out_path + .strip_prefix("public") + .unwrap_or(&post.out_path); + xml.push_str(&format!( + "{}/{}{}\n", + SITE_URL, + href.display(), + post.meta.date, + )); + } + + xml.push_str("\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 +} + +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("\n"); + } + html.push_str(&format!("

{}

\n
    \n", year)); + current_year = year; + } + let href = format!( + "/{}", + path.strip_prefix("public").unwrap_or(path).display() + ); + html.push_str(&format!( + "
  • - {}
  • \n", + date, href, title + )); + } + + if !current_year.is_empty() { + html.push_str("
\n"); + } + + html +} + +////////////////////////////////////////////////////////////////////////////// +// Watcher +////////////////////////////////////////////////////////////////////////////// + +fn watch_paths() -> Vec { + 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 = Vec::new(); + + fn register_new_dirs( + kq: i32, + watched: &mut Vec, + ) { + 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' -// -// 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 = env::args().collect(); - - match args.get(1).map(|s| s.as_str()) { - Some("build") => build(), - Some("serve") => serve(), - _ => eprintln!("usage: ksgg "), - } -} - -////////////////////////////////////////////////////////////////////////////// -// Log -////////////////////////////////////////////////////////////////////////////// - -static LOG_ENABLED: OnceLock = 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 = ""; - -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("", &format!("{SSE_SCRIPT}")); - 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, - part: Option, -} - -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) { - 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) { - 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 = 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( - "\n\ - \n\n\ - ijanc.org\n\ - https://ijanc.org\n\ - ijanc.org\n", - ); - - for post in items.iter().take(20) { - let href = post - .out_path - .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", - SITE_URL, - href.display(), - )); - xml.push_str(&format!( - "{}\n", - post.meta.description, - )); - xml.push_str(&format!( - "{}\n", - post.meta.date, - )); - xml.push_str("\n"); - } - - xml.push_str("\n\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( - "\n\ - \n", - ); - - // index - xml.push_str(&format!( - "{}/\n", - SITE_URL, - )); - - for post in posts { - let href = post - .out_path - .strip_prefix("public") - .unwrap_or(&post.out_path); - xml.push_str(&format!( - "{}/{}{}\n", - SITE_URL, - href.display(), - post.meta.date, - )); - } - - xml.push_str("\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 -} - -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("
    \n"); - for (date, title, path) in &items { - let href = format!( - "/{}", - path.strip_prefix("public").unwrap_or(path).display() - ); - html.push_str(&format!( - "
  • - {}
  • \n", - date, href, title - )); - } - - html.push_str("
"); - html -} - -////////////////////////////////////////////////////////////////////////////// -// Watcher -////////////////////////////////////////////////////////////////////////////// - -fn watch_paths() -> Vec { - 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 = 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 = 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 @@ - - - - - - 404 - - -

404

-

Page not found.

- back - - + +404 Not Found +

404 Not Found

blob - 9257329ed59fbce876f6fa8e8460cfd5f7602f76 blob + 593f25ef3fc79f620197be89cd09ae262882ccbf --- templates/base.html +++ templates/base.html @@ -1,13 +1,52 @@ - - - - - {{title}} - - - - {{content}} - {{posts}} - + + + + + + + + + + + {{title}} + + + + + +
+ {{content}} + {{posts}} +
+ +