// // MARK: metadata // use axum::{ Router, extract::{ConnectInfo, Path, State}, http::{HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, routing::get, }; use chrono::{DateTime, Utc}; use libservice::ServiceConnectInfo; use lru::LruCache; use maud::{Markup, Render, html}; use parking_lot::Mutex; use std::{collections::HashMap, num::NonZero, sync::Arc, time::Duration}; use tracing::{debug, trace}; use crate::components::{md::Markdown, misc::Backlinks}; #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct PageMetadata { pub title: String, pub author: Option, pub description: Option, pub image: Option, } impl Default for PageMetadata { fn default() -> Self { Self { title: "Untitled page".into(), author: None, description: None, image: None, } } } impl Render for PageMetadata { fn render(&self) -> Markup { let empty = String::new(); let title = &self.title; let author = &self.author.as_ref().unwrap_or(&empty); let description = &self.description.as_ref().unwrap_or(&empty); let image = &self.image.as_ref().unwrap_or(&empty); html !( meta property="og:site_name" content=(title) {} meta name="title" content=(title) {} meta property="og:title" content=(title) {} meta property="twitter:title" content=(title) {} meta name="author" content=(author) {} meta name="description" content=(description) {} meta property="og:description" content=(description) {} meta property="twitter:description" content=(description) {} meta content=(image) property="og:image" {} link rel="shortcut icon" href=(image) type="image/x-icon" {} ) } } // // MARK: page // // Some HTML pub struct Page { pub meta: PageMetadata, /// How long this page's html may be cached. /// /// If `None`, this page is always rendered from scratch. pub html_ttl: Option, /// A function that generates this page's html. /// /// This should return the contents of this page's tag, /// or the contents of a wrapper element (defined in the page server struct). /// /// This closure must never return `` or ``. pub generate_html: Box Markup>, } impl Default for Page { fn default() -> Self { Page { meta: Default::default(), html_ttl: Some(Duration::from_secs(60 * 24 * 30)), //css_ttl: Duration::from_secs(60 * 24 * 30), //generate_css: None, generate_html: Box::new(|_| html!()), } } } impl Page { pub fn generate_html(&self) -> Markup { (self.generate_html)(self) } pub fn from_markdown(meta: PageMetadata, md: impl Into) -> Self { let md: String = md.into(); // TODO: define metadata and backlinks in markdown Page { meta, generate_html: Box::new(move |page| { html! { (Backlinks(&[("/", "home")], &page.meta.title)) (Markdown(&md)) } }), ..Default::default() } } } // // MARK: server // pub struct PageServer { /// Map of `{ route: page }` pages: HashMap, /// Map of `{ route: (page data, expire time) }` /// /// We use an LruCache for bounded memory usage. html_cache: Mutex)>>, /// Called whenever we need to render a page. /// - this method should call `page.generate_html()`, /// - wrap the result in ``, /// - and add `` /// ``` render_page: Box Markup>, } impl PageServer { pub fn new(page_wrapper: Box Markup>) -> Self { #[expect(clippy::unwrap_used)] let cache_size = LruCache::new(NonZero::new(128).unwrap()); Self { pages: HashMap::new(), html_cache: Mutex::new(cache_size), render_page: Box::new(page_wrapper), } } pub fn add_page(mut self, route: impl Into, page: Page) -> Self { #[expect(clippy::expect_used)] let route = route .into() .strip_prefix("/") .expect("page route must start with /") .to_owned(); self.pages.insert(route, page); self } async fn handler( Path(path): Path, State(state): State>, ConnectInfo(addr): ConnectInfo, ) -> Response { trace!("Serving {path} to {}", addr.addr); let page = match state.pages.get(&path) { Some(x) => x, // TODO: 404 page None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(), }; let now = Utc::now(); let headers = [( header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"), )]; if let Some((html, expires)) = state.html_cache.lock().get(&path) && *expires > now { // TODO: no clone? return (headers, html.clone()).into_response(); }; debug!("Rendering {path}"); let html = (state.render_page)(page).0; if let Some(ttl) = page.html_ttl { state.html_cache.lock().put(path, (html.clone(), now + ttl)); } return (headers, html.clone()).into_response(); } pub fn into_router(self) -> Router<()> { Router::new() .route( "/", get(|state, conn| async { Self::handler(Path(String::new()), state, conn).await }), ) .route("/{*path}", get(Self::handler)) .with_state(Arc::new(self)) } }