// // MARK: metadata // use axum::{ Router, extract::{ConnectInfo, Path, State}, http::{HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, routing::get, }; use chrono::{DateTime, TimeDelta, Utc}; use libservice::ServiceConnectInfo; use markdown_it::Node; use maud::{Markup, PreEscaped, Render, html}; use parking_lot::{Mutex, RwLock}; use serde::Deserialize; use std::{ collections::HashMap, pin::Pin, sync::Arc, time::{Duration, Instant}, }; use tracing::{trace, warn}; use crate::components::{ md::{FrontMatter, Markdown}, misc::Backlinks, }; #[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)] pub struct PageMetadata { pub title: String, pub author: Option, pub description: Option, pub image: Option, pub slug: Option, } impl Default for PageMetadata { fn default() -> Self { Self { title: "Untitled page".into(), author: None, description: None, image: None, slug: 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" {} ) } } impl PageMetadata { /// Try to read page metadata from a markdown file's frontmatter. /// - returns `none` if there is no frontmatter /// - returns an error if we fail to parse frontmatter pub fn from_markdown_frontmatter( root_node: &Node, ) -> Result, serde_yaml::Error> { root_node .children .get(0) .map(|x| x.cast::()) .flatten() .map(|x| serde_yaml::from_str::(&x.content)) .map_or(Ok(None), |v| v.map(Some)) } } // // MARK: page // // Some HTML pub struct Page { pub meta: PageMetadata, /// How long this page's html may be cached. /// This controls the maximum age of a page shown to the user. /// /// 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< dyn Send + Sync + for<'a> Fn(&'a Page) -> Pin + 'a + Send + Sync>>, >, } impl Default for Page { fn default() -> Self { Page { meta: Default::default(), html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)), //css_ttl: Duration::from_secs(60 * 24 * 30), //generate_css: None, generate_html: Box::new(|_| Box::pin(async { html!() })), } } } impl Page { pub async fn generate_html(&self) -> Markup { (self.generate_html)(self).await } pub fn from_markdown(md: impl Into, default_image: Option) -> Self { let md: String = md.into(); let md = Markdown::parse(&md); let mut meta = PageMetadata::from_markdown_frontmatter(&md) .unwrap_or(Some(PageMetadata { title: "Invalid frontmatter!".into(), ..Default::default() })) .unwrap_or(Default::default()); if meta.image.is_none() { meta.image = default_image } let html = PreEscaped(md.render()); Page { meta, generate_html: Box::new(move |page| { let html = html.clone(); Box::pin(async move { html! { @if let Some(slug) = &page.meta.slug { (Backlinks(&[("/", "home")], slug)) } (html) } }) }), ..Default::default() } } } // // MARK: server // pub struct PageServer { /// If true, expired pages will be rerendered before being sent to the user. /// If false, requests never trigger rerenders. We rely on the rerender task. /// /// If true, we deliver fresher pages but delay responses. /// TODO: replace this with a smarter rendering strategy? never_rerender_on_request: bool, /// Map of `{ route: page }` pages: Arc>>>, /// Map of `{ route: (page data, expire time) }` /// /// We use an LruCache for bounded memory usage. html_cache: RwLock)>>, /// Called whenever we need to render a page. /// - this method should call `page.generate_html()`, /// - wrap the result in ``, /// - and add `` /// ``` render_page: Box< dyn Send + Sync + for<'a> Fn(&'a Page) -> Pin + 'a + Send + Sync>>, >, } impl PageServer { pub fn new( render_page: Box< dyn Send + Sync + for<'a> Fn(&'a Page) -> Pin + 'a + Send + Sync>>, >, ) -> Arc { Arc::new(Self { pages: Arc::new(Mutex::new(HashMap::new())), html_cache: RwLock::new(HashMap::new()), render_page, never_rerender_on_request: true, }) } pub fn add_page(&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.lock().insert(route, Arc::new(page)); self } /// Re-render the page at `route`, regardless of cache state. /// Does nothing if there is no page at `route`. /// /// Returns the rendered page's content. async fn render_page(&self, reason: &'static str, route: &str) -> Option { let now = Utc::now(); let start = Instant::now(); trace!(message = "Rendering page", route, reason); let page = match self.pages.lock().get(route) { Some(x) => x.clone(), None => { warn!(message = "Not rerendering, no such route", route, reason); return None; } }; let html = (self.render_page)(&*page).await.0; if let Some(ttl) = page.html_ttl { self.html_cache .write() .insert(route.to_owned(), (html.clone(), now + ttl)); } let elapsed = start.elapsed().as_millis(); trace!(message = "Rendered page", route, reason, time_ms = elapsed); return Some(html); } // Rerender considerations: // - rerendering often in the background is wasteful. Maybe we should fall asleep? // - rerendering on request is slow // - rerendering in the background after a request could be a good idea. Maybe implement? // // - cached pages only make sense for static assets. // - user pages can't be pre-rendered! pub async fn start_rerender_task(self: Arc, interval: Duration) { loop { tokio::time::sleep(interval).await; let now = Utc::now(); let pages = self .pages .lock() .iter() .filter(|(_, v)| v.html_ttl.is_some()) .map(|(k, _)| k.clone()) .collect::>(); for route in pages { let needs_render = match self.html_cache.read().get(&route) { Some(x) => x.1 < now, // Expired None => true, // Never rendered }; if needs_render { self.render_page("rerender_task", &route).await; } } } } async fn handler( Path(route): Path, State(state): State>, ConnectInfo(addr): ConnectInfo, ) -> Response { trace!("Serving {route} to {}", addr.addr); 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.read().get(&route) && (*expires > now || state.never_rerender_on_request) { // TODO: no clone? return (headers, html.clone()).into_response(); }; let html = match state.render_page("request", &route).await { Some(x) => x.clone(), None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(), }; return (headers, html).into_response(); } pub fn into_router(self: Arc) -> Router<()> { Router::new() .route( "/", get(|state, conn| async { Self::handler(Path(String::new()), state, conn).await }), ) .route("/{*path}", get(Self::handler)) .with_state(self) } }