use std::{ net::{Ipv4Addr, SocketAddrV4}, path::PathBuf, sync::Arc, }; use ::typst::syntax::VirtualPath; use axum::{ Router, extract::{Path, State}, http::StatusCode, response::{Html, IntoResponse, Response}, routing::get, }; use clap::Parser; use itertools::Itertools; use tower_http::{ compression::CompressionLayer, services::{ServeDir, ServeFile}, trace::TraceLayer, }; use tracing::{Level, info}; use tracing_subscriber::FmtSubscriber; use crate::typst::TypstContext; mod typst; #[derive(Clone)] struct AppState { typst: Arc, config: AppConfig, } /// A Typst-based (static) site generator. #[derive(Clone, Parser)] #[command(version, about)] struct AppConfig { /// The port to expose the site on. #[arg(short = 'p', long, default_value_t = 3000)] port: u16, /// The directory where typst files and related content are located. #[arg(short = 'c', long, value_name = "DIR", default_value = "./content")] content_root: PathBuf, /// The directory relative to content root where local packages are installed. #[arg(short = 's', long, value_name = "DIR", default_value = "common")] common_root: PathBuf, /// The directory relative to content root where assets are stored. /// Should be inside the content root to ensure that typst ran externally from /// karnaugh can also find them, this is not required however. #[arg(short = 'a', long, value_name = "DIR", default_value = "assets")] assets_root: PathBuf, /// The directory where packages required to render pages will be downloaded. #[arg(short = 'd', long, value_name = "DIR", default_value = "/tmp/karnaugh")] packages_root: PathBuf, /// The log level to use #[arg(short = 'l', long, value_name = "LEVEL", default_value_t = Level::INFO)] log_level: Level, } impl AppConfig { fn get_full_common_path(&self) -> PathBuf { let mut root = self.content_root.clone(); root.push(&self.common_root); root } fn get_full_assets_path(&self) -> PathBuf { let mut root = self.content_root.clone(); root.push(&self.assets_root); root } } impl AppState { fn new() -> Self { let config = AppConfig::parse(); let typst = Arc::new(TypstContext::new(config.clone())); Self { typst, config } } } #[tokio::main] async fn main() { let state = AppState::new(); FmtSubscriber::builder() .with_max_level(state.config.log_level) .init(); // TODO: Replace with config option let mut favicon = state.config.get_full_assets_path(); favicon.push(PathBuf::from("favicon.svg")); let app = Router::new() .route("/", get(root_handler)) .route("/{*path}", get(typst_handler)) .route_service("/favicon.ico", ServeFile::new(favicon)) .nest_service("/assets", ServeDir::new(&state.config.assets_root)) .with_state(state.clone()) .layer(TraceLayer::new_for_http()) .layer(CompressionLayer::new()); let socket = SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), state.config.port); info!("Serving Karnaugh on socket {}", socket); let listener = tokio::net::TcpListener::bind(socket) .await .expect(&format!("Failed to bind to socket {}", socket)); axum::serve(listener, app) .await .expect("Failed to serve site"); } async fn root_handler(State(state): State) -> impl IntoResponse { typst_handler(State(state), Path("index".into())).await } async fn typst_handler(State(state): State, Path(path): Path) -> Response { if path.starts_with('/') || path.contains("..") || path.contains('\\') { return StatusCode::BAD_REQUEST.into_response(); } let path = match VirtualPath::new(path) { Ok(path) => path, Err(_) => return StatusCode::BAD_REQUEST.into_response(), }; let path = path.with_extension("typ"); match state.typst.compile_document(path) { Ok(content) => Html(content).into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, // TODO: Fancy error formatting Html( #[allow(unstable_name_collisions)] err.into_iter() .map(|err| format!("{:?}", err)) .intersperse(String::from("
")) .collect::(), ), ) .into_response(), } }