319 lines
10 KiB
Rust
319 lines
10 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 rendering;
|
|
|
|
use crate::{
|
|
cli::{Cli, Commands},
|
|
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,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug)]
|
|
pub struct WikiConfig {
|
|
pub title: 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("/{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 get_summary_data(docs_dir: &PathBuf, is_static: bool) -> Vec<Page> {
|
|
let mut pages = Vec::new();
|
|
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")
|
|
|| path.file_name().and_then(|s| s.to_str()) == Some("_changelog.toml")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let filename_str = if is_static {
|
|
entry.file_name().to_string_lossy().into_owned()
|
|
} else {
|
|
path.file_stem()
|
|
.map(|s| s.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned())
|
|
};
|
|
|
|
let title = if let Ok(content) = tokio::fs::read_to_string(&path).await {
|
|
if let Ok(config) = toml::from_str::<WikiConfig>(&content) {
|
|
config.title
|
|
} else {
|
|
filename_str.clone()
|
|
}
|
|
} else {
|
|
filename_str.clone()
|
|
};
|
|
|
|
pages.push(Page {
|
|
filename: filename_str,
|
|
title,
|
|
datetime: "".to_string(),
|
|
});
|
|
}
|
|
}
|
|
pages.sort_by(|a, b| a.title.cmp(&b.title));
|
|
pages
|
|
}
|
|
|
|
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
|
|
.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(),
|
|
}
|
|
}
|
|
|
|
fn get_nav_links(dir: &PathBuf, current_file: &str) -> (Option<String>, Option<String>) {
|
|
let mut files: Vec<String> = std::fs::read_dir(dir)
|
|
.unwrap()
|
|
.filter_map(|entry| {
|
|
let path = entry.ok()?.path();
|
|
if path.extension()? == "toml" {
|
|
Some(path.file_name()?.to_str()?.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
files.sort();
|
|
let pos = files.iter().position(|f| f == current_file);
|
|
match pos {
|
|
Some(i) => {
|
|
let prev = if i == 0 {
|
|
Some(".".to_string())
|
|
} else {
|
|
files.get(i - 1).cloned()
|
|
};
|
|
let next = files.get(i + 1).cloned();
|
|
(prev, next)
|
|
}
|
|
None => (None, None),
|
|
}
|
|
}
|