From 38ef3c3fad1ececf00f3f7d297c19ff10fcec5a8 Mon Sep 17 00:00:00 2001 From: eiiko6 Date: Mon, 12 Jan 2026 19:19:44 +0100 Subject: [PATCH] refactor: added the build command to build into static files --- .gitignore | 2 + src/main.rs | 263 +++++++++++++++++++++++++++++-------------- templates/_base.html | 4 +- templates/style.css | 6 +- 4 files changed, 185 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index 1ac43fe..3dbce34 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target /themes /result +/example/*.html +/example/*.css diff --git a/src/main.rs b/src/main.rs index f1dc713..8432836 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use ax_models::Page; use axum::{ Router, extract::{Path, State}, @@ -8,7 +9,6 @@ use axum::{ use clap::{Parser, Subcommand}; use lazy_static::lazy_static; use pulldown_cmark::{Options, Parser as MarkdownParser, html}; -use serde::{Deserialize, Serialize}; use std::sync::Arc; use std::{io::Cursor, path::PathBuf}; use syntect::{highlighting::ThemeSet, parsing::SyntaxSet}; @@ -33,10 +33,7 @@ lazy_static! { 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!("../themes/Catppuccin-Macchiato.tmTheme"); 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) => { @@ -51,7 +48,7 @@ lazy_static! { } #[derive(Parser)] -#[command(author, version, about = "A simple markdown book server")] +#[command(author, version, about = "A simple markdown book server/builder")] struct Cli { #[command(subcommand)] command: Commands, @@ -59,9 +56,9 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// Serve the markdown files in a directory + /// Serve the markdown files dynamically Serve { - /// Path to the directory containing SUMMARY.md + /// Path to the directory containing markdown files path: PathBuf, /// Whether the home page and navbar should be removed @@ -76,6 +73,19 @@ enum Commands { #[arg(short = 'H', long)] host: bool, }, + /// Build static HTML files from the markdown directory + Build { + /// Path to the directory containing markdown files + path: PathBuf, + + /// Whether the home page and navbar should be removed + #[arg(short, long)] + no_navigation: bool, + + /// Output directory (defaults to the input directory) + #[arg(short, long)] + out_dir: Option, + }, } struct AppState { @@ -86,7 +96,6 @@ struct AppState { #[tokio::main] async fn main() -> anyhow::Result<()> { lazy_static::initialize(&TEMPLATES); - tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); @@ -101,15 +110,13 @@ async fn main() -> anyhow::Result<()> { no_navigation, } => { let abs_path = std::fs::canonicalize(&path)?; - let shared_state = Arc::new(AppState { docs_dir: abs_path, no_navigation, }); - let app = Router::new() - .route("/", get(render_summary)) - .route("/{page}", get(render_page)) + .route("/", get(render_summary_handler)) + .route("/{page}", get(render_page_handler)) .route("/style.css", get(serve_css)) .with_state(shared_state); @@ -118,72 +125,181 @@ async fn main() -> anyhow::Result<()> { } else { format!("127.0.0.1:{}", port) }; - let listener = tokio::net::TcpListener::bind(&addr).await?; - tracing::info!("Listening on {}", addr); + tracing::info!("Listening on http://{}", addr); axum::serve(listener, app).await?; } - } + Commands::Build { + path, + no_navigation, + out_dir, + } => { + let abs_path = std::fs::canonicalize(&path)?; + let output_path = out_dir.unwrap_or_else(|| abs_path.clone()); + tokio::fs::create_dir_all(&output_path).await?; + run_build(abs_path, output_path, no_navigation).await?; + } + } Ok(()) } -async fn render_summary(State(state): State>) -> impl IntoResponse { - if state.no_navigation { - return (StatusCode::NOT_FOUND, "Navigation is disabled").into_response(); - } - - let mut context = Context::new(); - context.insert("title", "Pages"); - - #[derive(Deserialize, Serialize)] - struct Page { - filename: String, - title: String, - datetime: String, - } - - let mut pages: Vec = Vec::new(); - if let Ok(mut entries) = tokio::fs::read_dir(&state.docs_dir).await { +async fn get_summary_data(docs_dir: &PathBuf) -> Vec { + 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("md") { + continue; + } + let filename = entry.file_name(); - let filename = filename.to_str().unwrap_or(""); + let filename_str = filename.to_str().unwrap_or(""); let title = if let Ok(file) = tokio::fs::File::open(&path).await { let mut reader = BufReader::new(file); let mut line = String::new(); match reader.read_line(&mut line).await { Ok(_) => line.trim_start_matches('#').trim().to_string(), - Err(_) => filename.to_string(), + Err(_) => filename_str.to_string(), } } else { - filename.to_string() + filename_str.to_string() }; - let datetime = filename + let datetime = filename_str .split_once('@') .and_then(|(_, ts_with_ext)| ts_with_ext.split('.').next()) .map(|dt| dt.to_string()) .unwrap_or_else(|| "Invalid Date".to_string()); pages.push(Page { - filename: filename.to_string(), + filename: filename_str.to_string(), title, datetime, }); } } + pages.sort_by(|a, b| b.datetime.cmp(&a.datetime)); + pages +} +async fn render_markdown_to_html( + content: &str, + filename: &str, + docs_dir: &PathBuf, + no_navigation: bool, + is_static: bool, +) -> String { + let mut options = Options::empty(); + options.insert( + Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS, + ); + + let parser = MarkdownParser::new_ext(content, options); + let renderer = CodeblockRenderer::new(parser); + let mut html_output = String::new(); + html::push_html(&mut html_output, renderer); + + let (mut prev, mut next) = if no_navigation { + (None, None) + } else { + get_nav_links(docs_dir, filename) + }; + + // If building statically, rewrite .md links to .html + if is_static { + prev = prev.map(|s| { + if s == "." { + "index.html".to_string() + } else { + s.replace(".md", ".html") + } + }); + next = next.map(|s| s.replace(".md", ".html")); + } + + let mut context = Context::new(); + context.insert("title", filename); + context.insert("content", &html_output); + context.insert("prev_page", &prev); + context.insert("next_page", &next); + context.insert("no_navigation", &no_navigation); + context.insert("is_static", &is_static); + + TEMPLATES + .render("page.html", &context) + .unwrap_or_else(|e| format!("Error: {}", e)) +} + +async fn run_build(docs_dir: PathBuf, out_dir: PathBuf, no_navigation: bool) -> anyhow::Result<()> { + tracing::info!("Building static site to: {:?}", out_dir); + + // Build summary + if !no_navigation { + let pages = get_summary_data(&docs_dir).await; + // Rewrite filenames for static links in home page + let static_pages: Vec = pages + .into_iter() + .map(|mut p| { + p.filename = p.filename.replace(".md", ".html"); + p + }) + .collect(); + + let mut context = Context::new(); + context.insert("title", "Pages"); + context.insert("files", &static_pages); + context.insert("is_static", &true); + + let rendered = TEMPLATES.render("home.html", &context)?; + tokio::fs::write(out_dir.join("index.html"), rendered).await?; + } + + // Build css + let css = TEMPLATES.render("style.css", &Context::new())?; + tokio::fs::write(out_dir.join("style.css"), css).await?; + + // Build pages + 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("md") { + let filename = entry.file_name().to_str().unwrap().to_string(); + let content = tokio::fs::read_to_string(&path).await?; + let rendered = + render_markdown_to_html(&content, &filename, &docs_dir, no_navigation, true).await; + + let out_file = out_dir.join(filename.replace(".md", ".html")); + tokio::fs::write(out_file, rendered).await?; + tracing::info!("Generated {}", filename); + } + } + + tracing::info!("Build complete!"); + Ok(()) +} + +async fn render_summary_handler(State(state): State>) -> impl IntoResponse { + if state.no_navigation { + return (StatusCode::NOT_FOUND, "Disabled").into_response(); + } + let pages = get_summary_data(&state.docs_dir).await; + let mut context = Context::new(); + context.insert("title", "Pages"); context.insert("files", &pages); + context.insert("is_static", &false); match TEMPLATES.render("home.html", &context) { Ok(rendered) => Html(rendered).into_response(), - Err(e) => Html(format!("

Template Error

{}
", e)).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } -async fn render_page( +async fn render_page_handler( State(state): State>, Path(page): Path, ) -> impl IntoResponse { @@ -192,18 +308,25 @@ async fn render_page( } else { format!("{}.md", page) }; - let file_path = state.docs_dir.join(&filename); - let content = match tokio::fs::read_to_string(&file_path).await { - Ok(c) => c, - Err(_) => return Html("

404

Page not found

".to_string()), - }; - render_md_file(&content, &filename, state).await + match tokio::fs::read_to_string(&file_path).await { + Ok(content) => Html( + render_markdown_to_html( + &content, + &filename, + &state.docs_dir, + state.no_navigation, + false, + ) + .await, + ), + Err(_) => Html("

404

Page not found

".to_string()), + } } async fn serve_css() -> impl IntoResponse { - match TEMPLATES.render("style.css", &tera::Context::new()) { + match TEMPLATES.render("style.css", &Context::new()) { Ok(css) => Response::builder() .header("content-type", "text/css") .body(css.into()) @@ -212,36 +335,14 @@ async fn serve_css() -> impl IntoResponse { } } -async fn render_md_file(content: &String, filename: &str, state: Arc) -> Html { - let mut options = Options::empty(); - options.insert(Options::ENABLE_TABLES); - options.insert(Options::ENABLE_FOOTNOTES); - options.insert(Options::ENABLE_STRIKETHROUGH); - options.insert(Options::ENABLE_TASKLISTS); - - let parser = MarkdownParser::new_ext(&content, options); - - let renderer = CodeblockRenderer::new(parser); - - let mut html_output = String::new(); - html::push_html(&mut html_output, renderer); - - let (prev_page, next_page) = if state.no_navigation { - (None, None) - } else { - get_nav_links(&state.docs_dir, filename) - }; - - let mut context = Context::new(); - context.insert("title", filename); - context.insert("content", &html_output); - context.insert("prev_page", &prev_page); - context.insert("next_page", &next_page); - context.insert("no_navigation", &state.no_navigation); - - match TEMPLATES.render("page.html", &context) { - Ok(rendered) => Html(rendered), - Err(e) => Html(format!("

Template Error

{}
", e)), +// Helper model for Tera +mod ax_models { + use serde::{Deserialize, Serialize}; + #[derive(Deserialize, Serialize, Clone)] + pub struct Page { + pub filename: String, + pub title: String, + pub datetime: String, } } @@ -259,27 +360,17 @@ fn get_nav_links(dir: &PathBuf, current_file: &str) -> (Option, Option { let prev = if i == 0 { - // If first page, point back to summary page Some(".".to_string()) } else { - // Otherwise point to the previous file in the list files.get(i - 1).cloned() }; - let next = files.get(i + 1).cloned(); (prev, next) } - None => (None, None), } } diff --git a/templates/_base.html b/templates/_base.html index e049f31..b6c4a9e 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -5,7 +5,7 @@ {{ title }} - + {% endblock head %} @@ -13,7 +13,7 @@ {% if not no_navigation %} {% endif %} diff --git a/templates/style.css b/templates/style.css index 7513578..3c936ac 100644 --- a/templates/style.css +++ b/templates/style.css @@ -38,8 +38,10 @@ body { font-size: 14px; } -article { - max-width: 1000px; +#content { + width: 100%; + max-width: var(--container-width); + min-width: 0; } ::selection {