added the changelog command

This commit is contained in:
2026-02-26 19:06:41 +01:00
parent 2973430c00
commit 5190291b30
10 changed files with 560 additions and 5 deletions

View File

@@ -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
```

52
example/_changelog.toml Normal file
View File

@@ -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",
]

109
flake.lock generated Normal file
View File

@@ -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
}

191
src/changelog.rs Normal file
View File

@@ -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<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ChangelogConfig {
pub entries: Vec<ChangelogEntry>,
}
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<String> = 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<ChangelogEntry> = 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<ChangelogEntry> {
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::<ChangelogConfig>(&content) {
return config.entries;
}
}
}
Vec::new()
}

View File

@@ -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<PathBuf>,
},
}

View File

@@ -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<Page> {
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<Page> {
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<Page> = 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 {

View File

@@ -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<Arc<AppState>>) -> 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!("<h1>404</h1><p>{}</p>", e)).into_response(),
}
}
pub async fn render_changelog_handler(State(state): State<Arc<AppState>>) -> 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(),
}
}

44
templates/changelog.html Normal file
View File

@@ -0,0 +1,44 @@
{% extends "_base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<div class="wiki-header">
<h1>{{ title }}</h1>
</div>
<div class="wiki-container">
<main class="wiki-body">
{% if changelog | length == 0 %}
<p>No changelog entries found.</p>
{% else %}
<ul class="changelog-list">
{% for entry in changelog %}
<li class="changelog-entry">
<div class="changelog-meta">
{# Split date "2026-02-26 15:10:00" into parts #}
{% set date_parts = entry.date | split(pat=" ") %}
<span class="changelog-day">{{ date_parts[0] }}</span>
<span class="changelog-time">{{ date_parts[1] }}</span>
<span class="changelog-author">by {{ entry.author }}</span>
</div>
<div class="changelog-message">{{ entry.message }}</div>
{% if entry.files %}
<div class="changelog-files">
<details>
<summary>{{ entry.files | length }} file(s) changed</summary>
<ul>
{% for file in entry.files %}
<li>{{ file }}</li>
{% endfor %}
</ul>
</details>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</main>
</div>
{% endblock content %}

View File

@@ -16,6 +16,21 @@
<hr />
<h2>Recent Changes</h2>
{% if changelog | length > 0 %}
<p><a href="{% if is_static %}changelog.html{% else %}changelog{% endif %}">View full changelog</a></p>
<ul>
{% for entry in changelog | slice(end=5) %}
<li>
<code style="color: var(--accent)">{{ entry.date | truncate(length=10, end="") }}</code>
{{ entry.message }}
</li>
{% endfor %}
</ul>
{% else %}
<p>No recent changes recorded.</p>
{% endif %}
<script>
document.addEventListener("DOMContentLoaded", function() {
const dateElements = document.querySelectorAll('.local-date');

View File

@@ -37,6 +37,7 @@ body {
width: 100%;
max-width: var(--container-width);
padding: 0 20px;
padding-bottom: 35px;
}
/* --- Wiki Layout --- */
@@ -171,3 +172,72 @@ nav {
z-index: 100;
}
nav a { font-weight: bold; font-size: 1.2rem; color: var(--text-main); }
.changelog-list {
list-style: none;
padding: 0;
}
.changelog-entry {
background: var(--container-bg);
border: 1px solid var(--border-color);
padding: 15px;
margin-bottom: 15px;
border-radius: var(--radius-sm);
}
.changelog-meta {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 5px;
display: flex;
gap: 10px;
align-items: baseline;
}
.changelog-day {
color: var(--accent);
font-weight: bold;
}
.changelog-time {
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
font-size: 0.8em;
}
.changelog-author {
margin-left: auto;
}
.changelog-message {
font-size: 1.05rem;
margin-bottom: 10px;
font-weight: 500;
}
.changelog-files {
font-size: 0.85rem;
margin-top: 8px;
}
.changelog-files summary {
cursor: pointer;
color: var(--text-muted);
list-style: none;
}
.changelog-files summary:hover {
color: var(--text-main);
}
.changelog-files summary::before {
content: '▶';
font-size: 0.7em;
margin-right: 5px;
display: inline-block;
transition: transform 0.2s;
}
.changelog-files details[open] summary::before {
transform: rotate(90deg);
}
.changelog-files ul {
margin: 5px 0 0 0;
padding-left: 20px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}