Files
wiki-maker/src/main.rs
2026-03-19 09:02:27 +01:00

271 lines
8.8 KiB
Rust

use axum::{
Router,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use clap::Parser;
use lazy_static::lazy_static;
use std::sync::Arc;
use std::{io::Cursor, path::PathBuf};
use syntect::{highlighting::ThemeSet, parsing::SyntaxSet};
use tera::{Context, Tera};
mod analysis;
mod changelog;
mod cli;
mod entry;
mod fs;
mod rendering;
use crate::{
cli::{Cli, Commands},
fs::{get_search_index, get_summary_data},
rendering::{
render_changelog_handler, render_page_handler, render_summary_handler, render_wiki_page,
},
};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
lazy_static! {
pub static ref TEMPLATES: Tera = {
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("_base.html", include_str!("../templates/_base.html")),
("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
};
pub static ref SYNTAX_SET: SyntaxSet = SyntaxSet::load_defaults_newlines();
pub static ref THEME_SET: ThemeSet = {
let mut set = ThemeSet::load_defaults();
let theme_bytes = include_bytes!(env!("THEME_FILE_PATH"));
let mut cursor = Cursor::new(theme_bytes);
match syntect::highlighting::ThemeSet::load_from_reader(&mut cursor) {
Ok(theme) => {
set.themes.insert("Catppuccin Macchiato".to_string(), theme);
}
Err(e) => {
tracing::error!("Failed to load embedded theme: {}", e);
}
}
set
};
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Page {
pub filename: String,
pub title: String,
pub datetime: String,
pub category: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct WikiConfig {
pub title: String,
pub category: Option<String>,
pub image: Option<String>,
pub infobox: Option<IndexMap<String, String>>,
pub content_file: Option<String>,
}
struct AppState {
docs_dir: PathBuf,
no_navigation: bool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
lazy_static::initialize(&TEMPLATES);
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let cli = Cli::parse();
// Resolve the global path argument once
let abs_path = std::fs::canonicalize(&cli.path)?;
match cli.command {
Commands::Serve {
port,
host,
no_navigation,
} => {
let shared_state = Arc::new(AppState {
docs_dir: abs_path,
no_navigation,
});
let app = Router::new()
.route("/", get(render_summary_handler))
.route("/search-index.json", get(get_search_index))
.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")),
)
.nest_service(
"/assets",
tower_http::services::ServeDir::new(&shared_state.docs_dir),
)
.with_state(shared_state);
let addr = if host {
format!("0.0.0.0:{}", port)
} else {
format!("127.0.0.1:{}", port)
};
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("Listening on http://{}", addr);
if no_navigation {
tracing::warn!("no_navigation is enabled: there will be no index at the root")
}
axum::serve(listener, app).await?;
}
Commands::Build {
no_navigation,
out_dir,
} => {
let output_path = out_dir;
tokio::fs::create_dir_all(&output_path).await?;
run_build(abs_path, output_path, no_navigation).await?;
}
Commands::Graph {} => {
let graph = analysis::WikiGraph::new(abs_path).await?;
graph.print_dot();
}
Commands::Todo { reverse } => {
let graph = analysis::WikiGraph::new(abs_path).await?;
graph.check_dead_links(reverse);
}
Commands::Entry { cmd } => {
entry::handle(cmd, abs_path).await?;
}
Commands::Changelog { cmd } => {
changelog::handle(cmd, abs_path).await?;
}
}
Ok(())
}
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 search_data: Vec<serde_json::Value> = pages
.iter()
.map(|p| {
serde_json::json!({
"title": p.title,
"url": p.filename.replace(".toml", ".html"),
"category": p.category
})
})
.collect();
let json_index = serde_json::to_string(&search_data)?;
let js_content = format!("window.WIKI_SEARCH_INDEX = {};", json_index);
tokio::fs::write(out_dir.join("search-index.js"), js_content).await?;
let static_pages: Vec<Page> = pages
.into_iter()
.map(|mut p| {
p.filename = p.filename.replace(".toml", ".html");
p
})
.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")
&& 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 {
continue;
};
match render_wiki_page(&filename, &docs_dir, no_navigation, true).await {
Ok(rendered) => {
let out_file = out_dir.join(filename.replace(".toml", ".html"));
tokio::fs::write(&out_file, rendered).await?;
tracing::info!("Generated {}", out_file.display());
}
Err(e) => tracing::error!("Failed to generate {}: {}", filename, e),
}
}
}
let out_images = out_dir.join("images");
tokio::fs::create_dir_all(&out_images).await?;
let src_images = docs_dir.join("images");
if src_images.exists() {
let mut entries = tokio::fs::read_dir(&src_images).await?;
while let Some(entry) = entries.next_entry().await? {
tracing::info!("Copied {}", entry.file_name().display(),);
let path = entry.path();
if path.is_file() {
let dest = out_images.join(if let Some(file_name) = path.file_name() {
file_name
} else {
continue;
});
tokio::fs::copy(path, dest).await?;
}
}
}
tracing::info!("Build complete!");
Ok(())
}
async fn serve_css() -> impl IntoResponse {
match TEMPLATES.render("style.css", &Context::new()) {
Ok(css) => Response::builder()
.header("content-type", "text/css")
.body(css.into())
.unwrap(),
Err(_) => (StatusCode::NOT_FOUND, "CSS not found").into_response(),
}
}