From 6cb54c230069e8f8ee90b3551b750afe04468382 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:31:48 -0800 Subject: [PATCH] Generic servable --- crates/lib/page/src/lib.rs | 4 +- crates/lib/page/src/servable/mod.rs | 2 + crates/lib/page/src/{ => servable}/page.rs | 34 +++++- crates/lib/page/src/servable/redirect.rs | 39 ++++++ crates/lib/page/src/server.rs | 115 +++++++++--------- .../service-webpage/src/components/md/mod.rs | 40 +----- .../service-webpage/src/pages/handouts.rs | 9 +- .../service-webpage/src/pages/index.rs | 12 +- .../service/service-webpage/src/pages/mod.rs | 107 +++++++++++++++- .../service/service-webpage/src/routes/mod.rs | 62 ++-------- 10 files changed, 263 insertions(+), 161 deletions(-) create mode 100644 crates/lib/page/src/servable/mod.rs rename crates/lib/page/src/{ => servable}/page.rs (76%) create mode 100644 crates/lib/page/src/servable/redirect.rs diff --git a/crates/lib/page/src/lib.rs b/crates/lib/page/src/lib.rs index 8c8d0f0..056e2f3 100644 --- a/crates/lib/page/src/lib.rs +++ b/crates/lib/page/src/lib.rs @@ -1,5 +1,5 @@ -mod page; -pub use page::*; +mod servable; +pub use servable::*; mod requestcontext; pub use requestcontext::*; diff --git a/crates/lib/page/src/servable/mod.rs b/crates/lib/page/src/servable/mod.rs new file mode 100644 index 0000000..964841f --- /dev/null +++ b/crates/lib/page/src/servable/mod.rs @@ -0,0 +1,2 @@ +pub mod page; +pub mod redirect; diff --git a/crates/lib/page/src/page.rs b/crates/lib/page/src/servable/page.rs similarity index 76% rename from crates/lib/page/src/page.rs rename to crates/lib/page/src/servable/page.rs index df6c20c..e8d2b5b 100644 --- a/crates/lib/page/src/page.rs +++ b/crates/lib/page/src/servable/page.rs @@ -1,9 +1,13 @@ +use axum::http::{ + HeaderMap, HeaderValue, StatusCode, + header::{self}, +}; use chrono::TimeDelta; use maud::{Markup, Render, html}; use serde::Deserialize; use std::pin::Pin; -use crate::RequestContext; +use crate::{Rendered, RequestContext, Servable}; // // MARK: metadata @@ -99,7 +103,31 @@ impl Default for Page { } impl Page { - pub async fn generate_html(&self, req_info: &RequestContext) -> Markup { - (self.generate_html)(self, req_info).await + pub async fn generate_html(&self, ctx: &RequestContext) -> Markup { + (self.generate_html)(self, ctx).await + } +} + +impl Servable for Page { + fn render<'a>( + &'a self, + ctx: &'a RequestContext, + ) -> Pin + 'a + Send + Sync>> { + Box::pin(async { + let mut headers = HeaderMap::with_capacity(3); + let html = self.generate_html(ctx).await; + + headers.append( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + + return Rendered { + code: StatusCode::OK, + headers, + body: html.0.into_bytes(), + ttl: self.html_ttl, + }; + }) } } diff --git a/crates/lib/page/src/servable/redirect.rs b/crates/lib/page/src/servable/redirect.rs new file mode 100644 index 0000000..0eccf1e --- /dev/null +++ b/crates/lib/page/src/servable/redirect.rs @@ -0,0 +1,39 @@ +use std::pin::Pin; + +use axum::http::{ + HeaderMap, HeaderValue, StatusCode, + header::{self, InvalidHeaderValue}, +}; + +use crate::{Rendered, RequestContext, Servable}; + +pub struct Redirect { + to: HeaderValue, +} + +impl Redirect { + pub fn new(to: impl Into) -> Result { + Ok(Self { + to: HeaderValue::from_str(&to.into())?, + }) + } +} + +impl Servable for Redirect { + fn render<'a>( + &'a self, + _ctx: &'a RequestContext, + ) -> Pin + 'a + Send + Sync>> { + Box::pin(async { + let mut headers = HeaderMap::with_capacity(1); + headers.append(header::LOCATION, self.to.clone()); + + return Rendered { + code: StatusCode::PERMANENT_REDIRECT, + headers, + body: Vec::new(), + ttl: None, + }; + }) + } +} diff --git a/crates/lib/page/src/server.rs b/crates/lib/page/src/server.rs index 304cc98..a3c716c 100644 --- a/crates/lib/page/src/server.rs +++ b/crates/lib/page/src/server.rs @@ -5,16 +5,31 @@ use axum::{ response::{IntoResponse, Response}, routing::get, }; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use libservice::ServiceConnectInfo; use lru::LruCache; -use maud::Markup; use parking_lot::Mutex; use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant}; use tower_http::compression::{CompressionLayer, DefaultPredicate}; use tracing::trace; -use crate::{ClientInfo, RequestContext, page::Page}; +use crate::{ClientInfo, RequestContext}; + +#[derive(Clone)] +pub struct Rendered { + pub code: StatusCode, + pub headers: HeaderMap, + pub body: Vec, + + pub ttl: Option, +} + +pub trait Servable: Send + Sync { + fn render<'a>( + &'a self, + ctx: &'a RequestContext, + ) -> Pin + 'a + Send + Sync>>; +} pub struct PageServer { /// If true, expired pages will be rerendered before being sent to the user. @@ -25,56 +40,32 @@ pub struct PageServer { never_rerender_on_request: bool, /// Map of `{ route: page }` - pages: Arc>>>, + pages: Arc>>>, /// 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< - dyn Send - + Sync - + for<'a> Fn( - &'a Page, - &'a RequestContext, - ) -> Pin + 'a + Send + Sync>>, - >, + page_cache: Mutex)>>, } impl PageServer { - pub fn new( - render_page: Box< - dyn Send - + Sync - + for<'a> Fn( - &'a Page, - &'a RequestContext, - ) -> Pin + 'a + Send + Sync>>, - >, - ) -> Arc { + pub fn new() -> Arc { #[expect(clippy::unwrap_used)] let cache_size = NonZero::new(128).unwrap(); Arc::new(Self { pages: Arc::new(Mutex::new(HashMap::new())), - html_cache: Mutex::new(LruCache::new(cache_size)), - render_page, + page_cache: Mutex::new(LruCache::new(cache_size)), never_rerender_on_request: true, }) } - pub fn add_page(&self, route: impl Into, page: Page) -> &Self { + pub fn add_page(&self, route: impl Into, page: S) -> &Self { #[expect(clippy::expect_used)] let route = route .into() .strip_prefix("/") - .expect("page route must start with /") + .expect("route must start with /") .to_owned(); self.pages.lock().insert(route, Arc::new(page)); @@ -89,8 +80,8 @@ impl PageServer { &self, reason: &'static str, route: &str, - req_ctx: RequestContext, - ) -> Option<(String, Option>)> { + ctx: RequestContext, + ) -> Option<(Rendered, Option>)> { let now = Utc::now(); let start = Instant::now(); let page = match self.pages.lock().get(route) { @@ -105,19 +96,20 @@ impl PageServer { lock_time_ms = start.elapsed().as_millis() ); - let html = (self.render_page)(&page, &req_ctx).await.0; + let rendered = page.render(&ctx).await; + //let html = (self.render_page)(&page, &req_ctx).await.0; let mut expires = None; - if let Some(ttl) = page.html_ttl { + if let Some(ttl) = rendered.ttl { expires = Some(now + ttl); - self.html_cache + self.page_cache .lock() - .put((route.to_owned(), req_ctx), (html.clone(), now + ttl)); + .put(ctx, (rendered.clone(), now + ttl)); } let elapsed = start.elapsed().as_millis(); trace!(message = "Rendered page", route, reason, time_ms = elapsed); - return Some((html, expires)); + return Some((rendered, expires)); } async fn handler( @@ -126,6 +118,7 @@ impl PageServer { ConnectInfo(addr): ConnectInfo, headers: HeaderMap, ) -> Response { + let start = Instant::now(); let client_info = ClientInfo::from_headers(&headers); let ua = headers .get("user-agent") @@ -172,24 +165,23 @@ impl PageServer { return (StatusCode::PERMANENT_REDIRECT, headers).into_response(); } - let req_ctx = RequestContext { + let ctx = RequestContext { client_info, route: format!("/{route}"), }; - let cache_key = (route.clone(), req_ctx.clone()); let now = Utc::now(); let mut html_expires = None; // Get from cache, if available - if let Some((html, expires)) = state.html_cache.lock().get(&cache_key) + if let Some((html, expires)) = state.page_cache.lock().get(&ctx) && (*expires > now || state.never_rerender_on_request) { html_expires = Some((html.clone(), Some(*expires))); }; if html_expires.is_none() { - html_expires = match state.render_page("request", &route, req_ctx).await { + html_expires = match state.render_page("request", &route, ctx).await { Some(x) => Some(x.clone()), None => { trace!( @@ -200,19 +192,22 @@ impl PageServer { device_type = ?client_info.device_type ); - return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(); + trace!( + message = "Served route", + route, + addr = ?addr.addr, + user_agent = ua, + device_type = ?client_info.device_type, + time_ns = start.elapsed().as_nanos() + ); + + return StatusCode::NOT_FOUND.into_response(); } }; } #[expect(clippy::unwrap_used)] - let (html, expires) = html_expires.unwrap(); - - let mut headers = HeaderMap::with_capacity(3); - headers.append( - header::CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - ); + let (mut html, expires) = html_expires.unwrap(); let max_age = match expires { Some(expires) => (expires - now).num_seconds().max(1), @@ -220,15 +215,25 @@ impl PageServer { }; #[expect(clippy::unwrap_used)] - headers.append( + html.headers.insert( header::CACHE_CONTROL, // immutable; public/private HeaderValue::from_str(&format!("immutable, public, max-age={}", max_age)).unwrap(), ); - headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile")); + html.headers + .insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile")); - return (headers, html).into_response(); + trace!( + message = "Served route", + route, + addr = ?addr.addr, + user_agent = ua, + device_type = ?client_info.device_type, + time_ns = start.elapsed().as_nanos() + ); + + return (html.code, html.headers, html.body).into_response(); } pub fn into_router(self: Arc) -> Router<()> { diff --git a/crates/service/service-webpage/src/components/md/mod.rs b/crates/service/service-webpage/src/components/md/mod.rs index e37f6b3..f66eb5e 100644 --- a/crates/service/service-webpage/src/components/md/mod.rs +++ b/crates/service/service-webpage/src/components/md/mod.rs @@ -2,7 +2,8 @@ use lazy_static::lazy_static; use markdown_it::generics::inline::full_link; use markdown_it::{MarkdownIt, Node}; use maud::{Markup, PreEscaped, Render, html}; -use page::{Page, PageMetadata, RequestContext}; +use page::RequestContext; +use page::page::{Page, PageMetadata}; use crate::components::md::emote::InlineEmote; use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter}; @@ -101,43 +102,6 @@ pub fn meta_from_markdown(root_node: &Node) -> Result, toml .map_or(Ok(None), |v| v.map(Some)) } -pub fn page_from_markdown(md: impl Into, default_image: Option) -> Page { - let md: String = md.into(); - let md = Markdown::parse(&md); - - let mut meta = meta_from_markdown(&md) - .unwrap_or(Some(PageMetadata { - title: "Invalid frontmatter!".into(), - ..Default::default() - })) - .unwrap_or_default(); - - if meta.image.is_none() { - meta.image = default_image - } - - let html = PreEscaped(md.render()); - - Page { - meta, - generate_html: Box::new(move |page, ctx| { - let html = html.clone(); - - Box::pin(async move { - html! { - @if let Some(backlinks) = backlinks(page, ctx) { - (backlinks) - } - - (html) - } - }) - }), - - ..Default::default() - } -} - pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option { let mut last = None; let mut backlinks = vec![("/", "home")]; diff --git a/crates/service/service-webpage/src/pages/handouts.rs b/crates/service/service-webpage/src/pages/handouts.rs index cf27b36..cdadb01 100644 --- a/crates/service/service-webpage/src/pages/handouts.rs +++ b/crates/service/service-webpage/src/pages/handouts.rs @@ -7,7 +7,7 @@ use std::{ use assetserver::Asset; use chrono::{DateTime, TimeDelta, Utc}; use maud::{Markup, PreEscaped, html}; -use page::{DeviceType, Page, RequestContext}; +use page::{DeviceType, RequestContext, page::Page}; use parking_lot::Mutex; use serde::Deserialize; use tracing::{debug, warn}; @@ -17,6 +17,7 @@ use crate::{ md::{Markdown, backlinks, meta_from_markdown}, misc::FarLink, }, + pages::page_wrapper, routes::assets::Image_Icon, }; @@ -234,7 +235,7 @@ pub fn handouts() -> Page { Err(_) => fallback, }; - html! { + let inner = html! { @if let Some(backlinks) = backlinks(page, ctx) { (backlinks) } @@ -260,7 +261,9 @@ pub fn handouts() -> Page { ))) (advanced) br {} - } + }; + + page_wrapper(&page.meta, inner).await }) }), } diff --git a/crates/service/service-webpage/src/pages/index.rs b/crates/service/service-webpage/src/pages/index.rs index 1ebe8d0..5b8b3e2 100644 --- a/crates/service/service-webpage/src/pages/index.rs +++ b/crates/service/service-webpage/src/pages/index.rs @@ -1,6 +1,6 @@ use assetserver::Asset; use maud::html; -use page::{Page, PageMetadata}; +use page::page::{Page, PageMetadata}; use crate::{ components::{ @@ -9,6 +9,7 @@ use crate::{ md::Markdown, misc::FarLink, }, + pages::page_wrapper, routes::assets::{Image_Cover, Image_Icon}, }; @@ -22,9 +23,9 @@ pub fn index() -> Page { backlinks: Some(false), }, - generate_html: Box::new(move |_page, _| { + generate_html: Box::new(move |page, _ctx| { Box::pin(async { - html! { + let inner = html! { h2 id="about" { "About" } div { @@ -70,9 +71,12 @@ pub fn index() -> Page { } (Markdown(include_str!("index.md"))) - } + }; + + page_wrapper(&page.meta, inner).await }) }), + ..Default::default() } } diff --git a/crates/service/service-webpage/src/pages/mod.rs b/crates/service/service-webpage/src/pages/mod.rs index e4b8f56..e905657 100644 --- a/crates/service/service-webpage/src/pages/mod.rs +++ b/crates/service/service-webpage/src/pages/mod.rs @@ -1,7 +1,16 @@ use assetserver::Asset; -use page::Page; +use chrono::TimeDelta; +use maud::{DOCTYPE, Markup, PreEscaped, html}; +use page::page::{Page, PageMetadata}; +use std::pin::Pin; -use crate::{components::md::page_from_markdown, routes::assets::Image_Icon}; +use crate::{ + components::{ + md::{Markdown, backlinks, meta_from_markdown}, + misc::FarLink, + }, + routes::assets::{Image_Icon, Styles_Main}, +}; mod handouts; mod index; @@ -33,3 +42,97 @@ pub fn htwah_typesetting() -> Page { Some(Image_Icon::URL.to_owned()), ) } + +// +// MARK: md +// + +fn page_from_markdown(md: impl Into, default_image: Option) -> Page { + let md: String = md.into(); + let md = Markdown::parse(&md); + + let mut meta = meta_from_markdown(&md) + .unwrap_or(Some(PageMetadata { + title: "Invalid frontmatter!".into(), + ..Default::default() + })) + .unwrap_or_default(); + + if meta.image.is_none() { + meta.image = default_image + } + + let html = PreEscaped(md.render()); + + Page { + meta, + + html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)), + generate_html: Box::new(move |page, ctx| { + let html = html.clone(); + + Box::pin(async move { + let inner = html! { + @if let Some(backlinks) = backlinks(page, ctx) { + (backlinks) + } + + (html) + }; + + page_wrapper(&page.meta, inner).await + }) + }), + } +} + +// +// MARK: wrapper +// + +pub fn page_wrapper<'a>( + meta: &'a PageMetadata, + inner: Markup, +) -> Pin + 'a + Send + Sync>> { + Box::pin(async move { + html! { + (DOCTYPE) + html { + head { + meta charset="UTF" {} + meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {} + meta content="text/html; charset=UTF-8" http-equiv="content-type" {} + meta property="og:type" content="website" {} + + link rel="stylesheet" href=(Styles_Main::URL) {} + + (&meta) + title { (PreEscaped(meta.title.clone())) } + } + + body { + div class="wrapper" { + main { (inner) } + + footer { + hr class = "footline" {} + div class = "footContainer" { + p { + "This site was built by hand using " + (FarLink("https://rust-lang.org", "Rust")) + ", " + (FarLink("https://maud.lambda.xyz", "Maud")) + ", " + (FarLink("https://github.com/connorskees/grass", "Grass")) + ", and " + (FarLink("https://docs.rs/axum/latest/axum", "Axum")) + "." + } + } + } + } + } + } + } + }) +} diff --git a/crates/service/service-webpage/src/routes/mod.rs b/crates/service/service-webpage/src/routes/mod.rs index 9eb7e47..6212590 100644 --- a/crates/service/service-webpage/src/routes/mod.rs +++ b/crates/service/service-webpage/src/routes/mod.rs @@ -1,12 +1,9 @@ -use std::{pin::Pin, sync::Arc}; - -use assetserver::Asset; use axum::Router; -use maud::{DOCTYPE, Markup, PreEscaped, html}; -use page::{Page, PageServer, RequestContext}; +use page::{PageServer, redirect::Redirect}; +use std::sync::Arc; use tracing::info; -use crate::{components::misc::FarLink, pages, routes::assets::Styles_Main}; +use crate::pages; pub mod assets; @@ -20,63 +17,20 @@ pub(super) fn router() -> Router<()> { } fn build_server() -> Arc { - let server = PageServer::new(Box::new(page_wrapper)); + let server = PageServer::new(); + + #[expect(clippy::unwrap_used)] server .add_page("/", pages::index()) .add_page("/links", pages::links()) .add_page("/whats-a-betalupi", pages::betalupi()) .add_page("/handouts", pages::handouts()) + .add_page("/htwah", Redirect::new("/handouts").unwrap()) .add_page("/htwah/typesetting", pages::htwah_typesetting()); + server } -fn page_wrapper<'a>( - page: &'a Page, - req_ctx: &'a RequestContext, -) -> Pin + 'a + Send + Sync>> { - Box::pin(async move { - html! { - (DOCTYPE) - html { - head { - meta charset="UTF" {} - meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {} - meta content="text/html; charset=UTF-8" http-equiv="content-type" {} - meta property="og:type" content="website" {} - - link rel="stylesheet" href=(Styles_Main::URL) {} - - (&page.meta) - title { (PreEscaped(page.meta.title.clone())) } - } - - body { - div class="wrapper" { - main { ( page.generate_html(req_ctx).await ) } - - footer { - hr class = "footline" {} - div class = "footContainer" { - p { - "This site was built by hand using " - (FarLink("https://rust-lang.org", "Rust")) - ", " - (FarLink("https://maud.lambda.xyz", "Maud")) - ", " - (FarLink("https://github.com/connorskees/grass", "Grass")) - ", and " - (FarLink("https://docs.rs/axum/latest/axum", "Axum")) - "." - } - } - } - } - } - } - } - }) -} - #[test] fn server_builds_without_panic() { tokio::runtime::Builder::new_current_thread()