From 5190291b30abcb8d5b57d13f0b767addce9964df Mon Sep 17 00:00:00 2001 From: eiiko6 Date: Thu, 26 Feb 2026 19:06:41 +0100 Subject: [PATCH] added the changelog command --- README.md | 5 + example/_changelog.toml | 52 +++++++++++ flake.lock | 109 ++++++++++++++++++++++ src/changelog.rs | 191 +++++++++++++++++++++++++++++++++++++++ src/cli.rs | 18 ++++ src/main.rs | 35 ++++++- src/rendering.rs | 26 +++++- templates/changelog.html | 44 +++++++++ templates/home.html | 15 +++ templates/style.css | 70 ++++++++++++++ 10 files changed, 560 insertions(+), 5 deletions(-) create mode 100644 example/_changelog.toml create mode 100644 flake.lock create mode 100644 src/changelog.rs create mode 100644 templates/changelog.html diff --git a/README.md b/README.md index 69a2edc..1839ee8 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,8 @@ Or you can generate a static build with: ```sh wiki-maker --path ./example/ build -o ./dist/ ``` + +You can generate and update a changelog page based on git history with: +```sh +wiki-maker --path ./example/ changelog gen-from-git +``` diff --git a/example/_changelog.toml b/example/_changelog.toml new file mode 100644 index 0000000..fd3ada4 --- /dev/null +++ b/example/_changelog.toml @@ -0,0 +1,52 @@ +# This file was generated by `wiki-maker changelog gen-from-git` + +[[entries]] +date = "2026-02-26 09:06:06" +message = "fixed and improved images, and generalized renderer" +author = "eiiko6" +hash = "b854e4b633326f020005b8ae67b99da21788408a" +files = ["images/bernardo.png"] + +[[entries]] +date = "2026-02-13 18:20:33" +message = "converted to wiki" +author = "eiiko6" +hash = "0ca4161e036d2735f15aefc87def7ec8bab0902d" +files = [ + "azrak.md", + "azrak.toml", + "bernardo.md", + "bernardo.png", + "bernardo.toml", + "coffee@1767815445.md", + "ferris@1767468388.md", + "lumina.md", + "lumina.toml", + "space@1767813388.md", + "voidmother.md", + "voidmother.toml", +] + +[[entries]] +date = "2026-01-07 20:55:32" +message = "implemented automatic summary generation, and added timestamps in page list" +author = "eiiko6" +hash = "dcae9feac795bd338c30d11a29d20834ff4101cd" +files = [ + "SUMMARY.md", + "coffee@1767815445.md", + "ferris@1767468388.md", + "space@1767813388.md", +] + +[[entries]] +date = "2026-01-07 18:29:07" +message = "base markdown book with code block syntax highlighting and fully embedded assets" +author = "eiiko6" +hash = "1c4e99b138a17eb6368c097df0945928c26e0b5d" +files = [ + "01-ferris.md", + "02-coffee.md", + "03-space.md", + "SUMMARY.md", +] diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..2a1ad86 --- /dev/null +++ b/flake.lock @@ -0,0 +1,109 @@ +{ + "nodes": { + "code-theme": { + "flake": false, + "locked": { + "narHash": "sha256-28LGXKITHbrmt6qrcG/W+qTlaWthre7x7izp/TPjQgA=", + "type": "file", + "url": "https://raw.githubusercontent.com/catppuccin/bat/refs/heads/main/themes/Catppuccin%20Macchiato.tmTheme" + }, + "original": { + "type": "file", + "url": "https://raw.githubusercontent.com/catppuccin/bat/refs/heads/main/themes/Catppuccin%20Macchiato.tmTheme" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "code-theme": "code-theme", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1772075164, + "narHash": "sha256-93XcvAt+6p7aAq1ERlxD2T17zLGoYGo64KJYasGcpgc=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "07601339b15fa6810541c0e7dc2f3664d92a7ad0", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/src/changelog.rs b/src/changelog.rs new file mode 100644 index 0000000..8481b1b --- /dev/null +++ b/src/changelog.rs @@ -0,0 +1,191 @@ +use anyhow::{Context, Result, anyhow}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tokio::fs; + +use crate::cli::ChangelogCommands; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ChangelogEntry { + pub date: String, + pub message: String, + pub author: String, + pub hash: String, + #[serde(default)] + pub files: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct ChangelogConfig { + pub entries: Vec, +} + +pub async fn handle(cmd: ChangelogCommands, root_dir: PathBuf) -> Result<()> { + let changelog_path = root_dir.join("_changelog.toml"); + + match cmd { + ChangelogCommands::View => { + if !changelog_path.exists() { + println!("No changelog found at {:?}", changelog_path); + return Ok(()); + } + let content = fs::read_to_string(&changelog_path).await?; + let config: ChangelogConfig = toml::from_str(&content)?; + + println!("Changelog ({} entries):", config.entries.len()); + for entry in config.entries { + println!("- [{}] {} ({})", entry.date, entry.message, entry.author); + if !entry.files.is_empty() { + println!(" Files: {:?}\n", entry.files); + } + } + } + ChangelogCommands::GenFromGit { git_path } => { + let git_dir = git_path.unwrap_or_else(|| root_dir.clone()); + update_from_git(&changelog_path, &git_dir, &root_dir).await?; + } + } + Ok(()) +} + +async fn update_from_git(changelog_path: &Path, git_dir: &Path, wiki_root: &Path) -> Result<()> { + let output_root = Command::new("git") + .arg("-C") + .arg(git_dir) + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("Failed to find git root")?; + + let repo_root_str = String::from_utf8(output_root.stdout)?.trim().to_string(); + let repo_root = PathBuf::from(&repo_root_str); + + let prefix_path = wiki_root.strip_prefix(&repo_root).unwrap_or(Path::new("")); + + let mut prefix = prefix_path.to_string_lossy().to_string().replace('\\', "/"); + if !prefix.is_empty() && !prefix.ends_with('/') { + prefix.push('/'); + } + + // Load existing + let mut config = if changelog_path.exists() { + let content = fs::read_to_string(changelog_path).await?; + toml::from_str(&content).unwrap_or_default() + } else { + ChangelogConfig::default() + }; + + let existing_hashes: HashSet = config.entries.iter().map(|e| e.hash.clone()).collect(); + + let output = Command::new("git") + .arg("-C") + .arg(git_dir) + .args([ + "log", + "--name-only", + "--pretty=format:---ENTRY---|%H|%ad|%an|%s", + "--date=format:%Y-%m-%d %H:%M:%S", + "--", + ".", + ]) + .output() + .context("Failed to execute git command")?; + + if !output.status.success() { + return Err(anyhow!( + "Git command failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let stdout = String::from_utf8(output.stdout)?; + let mut new_entries = Vec::new(); + let mut current_entry: Option = None; + + for line in stdout.lines() { + if line.starts_with("---ENTRY---|") { + // Push previous + if let Some(entry) = current_entry.take() { + if !entry.files.is_empty() && !existing_hashes.contains(&entry.hash) { + new_entries.push(entry); + } + } + + // Parse new + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 5 { + let hash = parts[1].to_string(); + let date = parts[2].to_string(); + let author = parts[3].to_string(); + let message = parts[4..].join("|"); + + current_entry = Some(ChangelogEntry { + hash, + date, + author, + message, + files: Vec::new(), + }); + } + } else if !line.trim().is_empty() { + let raw_path = line.trim(); + + // Check if file belongs to the wiki directory + if prefix.is_empty() || raw_path.starts_with(&prefix) { + if let Some(entry) = current_entry.as_mut() { + // Strip the prefix to get the relative path inside the wiki + let clean_path = if prefix.is_empty() { + raw_path.to_string() + } else { + raw_path + .strip_prefix(&prefix) + .unwrap_or(raw_path) + .to_string() + }; + + if clean_path != "changelog.toml" { + entry.files.push(clean_path); + } + } + } + } + } + + // Push last + if let Some(entry) = current_entry { + if !entry.files.is_empty() && !existing_hashes.contains(&entry.hash) { + new_entries.push(entry); + } + } + + if new_entries.is_empty() { + println!("No new commits touching the wiki directory found."); + return Ok(()); + } + + println!("Found {} new commits.", new_entries.len()); + + config.entries.extend(new_entries); + config.entries.sort_by(|a, b| b.date.cmp(&a.date)); + + let mut toml_str = + String::from("# This file was generated by `wiki-maker changelog gen-from-git`\n\n"); + toml_str.push_str(&toml::to_string_pretty(&config)?); + fs::write(changelog_path, toml_str).await?; + println!("Updated {:?}", changelog_path); + + Ok(()) +} + +pub async fn load_changelog(root_dir: &Path) -> Vec { + let path = root_dir.join("_changelog.toml"); + if path.exists() { + if let Ok(content) = fs::read_to_string(path).await { + if let Ok(config) = toml::from_str::(&content) { + return config.entries; + } + } + } + Vec::new() +} diff --git a/src/cli.rs b/src/cli.rs index 3072ce2..5560336 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,6 +45,11 @@ pub enum Commands { #[command(subcommand)] cmd: EntryCommands, }, + /// Manage the changelog + Changelog { + #[command(subcommand)] + cmd: ChangelogCommands, + }, } #[derive(Subcommand)] @@ -67,3 +72,16 @@ pub enum EntryCommands { name: String, }, } + +#[derive(Subcommand)] +pub enum ChangelogCommands { + /// View the current changelog + View, + /// Generate or update the changelog from git history + GenFromGit { + /// Path to the directory containing `.git/` (defaults to current dir or wiki dir). + /// Note that git will look in parent directories until it finds a `.git/` + #[arg(long)] + git_path: Option, + }, +} diff --git a/src/main.rs b/src/main.rs index 0c95d81..58e5cb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,13 +12,16 @@ use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; use tera::{Context, Tera}; mod analysis; +mod changelog; mod cli; mod entry; mod rendering; use crate::{ cli::{Cli, Commands}, - rendering::{render_page_handler, render_summary_handler, render_wiki_page}, + rendering::{ + render_changelog_handler, render_page_handler, render_summary_handler, render_wiki_page, + }, }; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -31,6 +34,10 @@ lazy_static! { ("home.html", include_str!("../templates/home.html")), ("page.html", include_str!("../templates/page.html")), ("style.css", include_str!("../templates/style.css")), + ( + "changelog.html", + include_str!("../templates/changelog.html"), + ), ]) .unwrap(); tera @@ -98,6 +105,7 @@ async fn main() -> anyhow::Result<()> { .route("/", get(render_summary_handler)) .route("/{page}", get(render_page_handler)) .route("/style.css", get(serve_css)) + .route("/changelog", get(render_changelog_handler)) .nest_service( "/images", tower_http::services::ServeDir::new(&shared_state.docs_dir.join("images")), @@ -139,6 +147,9 @@ async fn main() -> anyhow::Result<()> { Commands::Entry { cmd } => { entry::handle(cmd, abs_path).await?; } + Commands::Changelog { cmd } => { + changelog::handle(cmd, abs_path).await?; + } } Ok(()) } @@ -148,7 +159,9 @@ async fn get_summary_data(docs_dir: &PathBuf, is_static: bool) -> Vec { if let Ok(mut entries) = tokio::fs::read_dir(docs_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) != Some("toml") { + if path.extension().and_then(|s| s.to_str()) != Some("toml") + || path.file_name().and_then(|s| s.to_str()) == Some("_changelog.toml") + { continue; } @@ -184,6 +197,8 @@ async fn get_summary_data(docs_dir: &PathBuf, is_static: bool) -> Vec { async fn run_build(docs_dir: PathBuf, out_dir: PathBuf, no_navigation: bool) -> anyhow::Result<()> { tracing::info!("Building static site to: {}", out_dir.display()); + let changelog_entries = changelog::load_changelog(&docs_dir).await; + if !no_navigation { let pages = get_summary_data(&docs_dir, true).await; let static_pages: Vec = pages @@ -194,22 +209,34 @@ async fn run_build(docs_dir: PathBuf, out_dir: PathBuf, no_navigation: bool) -> }) .collect(); + // Render home page let mut context = Context::new(); context.insert("title", "Wiki Index"); context.insert("files", &static_pages); + context.insert("changelog", &changelog_entries); context.insert("is_static", &true); - let rendered = TEMPLATES.render("home.html", &context)?; tokio::fs::write(out_dir.join("index.html"), rendered).await?; } + // Render changelog page + let mut context = Context::new(); + context.insert("title", "Changelog"); + context.insert("changelog", &changelog_entries); + context.insert("is_static", &true); + context.insert("no_navigation", &false); + let rendered = TEMPLATES.render("changelog.html", &context)?; + tokio::fs::write(out_dir.join("changelog.html"), &rendered).await?; + let css = TEMPLATES.render("style.css", &Context::new())?; tokio::fs::write(out_dir.join("style.css"), css).await?; let mut entries = tokio::fs::read_dir(&docs_dir).await?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) == Some("toml") { + if path.extension().and_then(|s| s.to_str()) == Some("toml") + && path.file_name().and_then(|s| s.to_str()) != Some("_changelog.toml") + { let filename = if let Some(file_name) = entry.file_name().to_str() { file_name.to_string() } else { diff --git a/src/rendering.rs b/src/rendering.rs index 535e3d4..b53a4ca 100644 --- a/src/rendering.rs +++ b/src/rendering.rs @@ -13,7 +13,8 @@ use syntect::html::highlighted_html_for_string; use tera::Context; use crate::{ - AppState, SYNTAX_SET, TEMPLATES, THEME_SET, WikiConfig, get_nav_links, get_summary_data, + AppState, SYNTAX_SET, TEMPLATES, THEME_SET, WikiConfig, changelog, get_nav_links, + get_summary_data, }; pub struct Renderer<'a> { @@ -202,10 +203,14 @@ pub async fn render_summary_handler(State(state): State>) -> impl if state.no_navigation { return (StatusCode::NOT_FOUND, "Disabled").into_response(); } + let pages = get_summary_data(&state.docs_dir, false).await; + let changelog_entries = changelog::load_changelog(&state.docs_dir).await; + let mut context = Context::new(); context.insert("title", "Wiki Index"); context.insert("files", &pages); + context.insert("changelog", &changelog_entries); context.insert("is_static", &false); match TEMPLATES.render("home.html", &context) { @@ -229,3 +234,22 @@ pub async fn render_page_handler( Err(e) => (StatusCode::NOT_FOUND, format!("

404

{}

", e)).into_response(), } } + +pub async fn render_changelog_handler(State(state): State>) -> impl IntoResponse { + if state.no_navigation { + return (StatusCode::NOT_FOUND, "Disabled").into_response(); + } + + let changelog_entries = changelog::load_changelog(&state.docs_dir).await; + + let mut context = Context::new(); + context.insert("title", "Changelog"); + context.insert("changelog", &changelog_entries); + context.insert("is_static", &false); + context.insert("no_navigation", &false); + + match TEMPLATES.render("changelog.html", &context) { + Ok(rendered) => Html(rendered).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/templates/changelog.html b/templates/changelog.html new file mode 100644 index 0000000..c8f9826 --- /dev/null +++ b/templates/changelog.html @@ -0,0 +1,44 @@ +{% extends "_base.html" %} +{% block title %}{{ title }}{% endblock title %} +{% block content %} +
+

{{ title }}

+
+ +
+
+ {% if changelog | length == 0 %} +

No changelog entries found.

+ {% else %} +
    + {% for entry in changelog %} +
  • +
    + {# Split date "2026-02-26 15:10:00" into parts #} + {% set date_parts = entry.date | split(pat=" ") %} + {{ date_parts[0] }} + {{ date_parts[1] }} + by {{ entry.author }} +
    + +
    {{ entry.message }}
    + + {% if entry.files %} +
    +
    + {{ entry.files | length }} file(s) changed +
      + {% for file in entry.files %} +
    • {{ file }}
    • + {% endfor %} +
    +
    +
    + {% endif %} +
  • + {% endfor %} +
+ {% endif %} +
+
+{% endblock content %} diff --git a/templates/home.html b/templates/home.html index a71d3f8..c2cb616 100644 --- a/templates/home.html +++ b/templates/home.html @@ -16,6 +16,21 @@
+

Recent Changes

+ {% if changelog | length > 0 %} +

View full changelog

+
    + {% for entry in changelog | slice(end=5) %} +
  • + {{ entry.date | truncate(length=10, end="") }} + {{ entry.message }} +
  • + {% endfor %} +
+ {% else %} +

No recent changes recorded.

+ {% endif %} +