commit - d4e8dba86b211177a1d053dbadb608da783f6c18
commit + ca3ca403aa8ffe3108c43b202a7709e3bc043912
blob - 814a7a92844f5ca7540b13920185dadb11dec250
blob + 80b31097717545e53b8f84e85553f12bbde9c274
--- .gitignore
+++ .gitignore
-target
+build
public
+vendor
blob - 594a4ecf89af40ed3c3ba7c072a4032fbc3cbd6c (mode 644)
blob + /dev/null
--- Cargo.lock
+++ /dev/null
-# 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
-[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
-.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
+.\"
+.\" 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
-# 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
+// 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
----
-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
----
-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
----
-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
----
-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
----
-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
-// 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
-body { max-width: 40em; margin: 0 auto; }
blob - 39e1901dc54abdb3d1413ce915f7c14eaaaf1ef7
blob + f8414f0ed249bcd37e1e632bd5efb65286cf87ee
--- templates/404.html
+++ templates/404.html
-<!-- 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
<!-- 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"><</a>
+ <a href="https://geekring.net/">GeekRing</a>
+ <a href="http://geekring.net/site/NUMBER/next">></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>