From 2ee3ad3898cfeec274310be1d4211325a54aac7a Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:59:20 -0800 Subject: [PATCH] Refactor --- Cargo.lock | 17 +- Cargo.toml | 1 + crates/lib/page/Cargo.toml | 20 ++ crates/lib/page/src/lib.rs | 8 + crates/lib/page/src/page.rs | 105 ++++++++ crates/lib/page/src/requestcontext.rs | 60 +++++ .../page/mod.rs => lib/page/src/server.rs} | 230 +----------------- crates/service/service-webpage/Cargo.toml | 2 +- .../service-webpage/src/components/md.rs | 56 ++++- crates/service/service-webpage/src/lib.rs | 1 - .../service-webpage/src/pages/handouts.rs | 8 +- .../service-webpage/src/pages/index.rs | 2 +- .../service/service-webpage/src/pages/mod.rs | 7 +- .../service/service-webpage/src/routes/mod.rs | 8 +- 14 files changed, 280 insertions(+), 245 deletions(-) create mode 100644 crates/lib/page/Cargo.toml create mode 100644 crates/lib/page/src/lib.rs create mode 100644 crates/lib/page/src/page.rs create mode 100644 crates/lib/page/src/requestcontext.rs rename crates/{service/service-webpage/src/page/mod.rs => lib/page/src/server.rs} (50%) diff --git a/Cargo.lock b/Cargo.lock index 3676dec..8aa62f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1536,6 +1536,21 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "page" +version = "0.0.1" +dependencies = [ + "axum", + "chrono", + "libservice", + "lru", + "maud", + "parking_lot", + "serde", + "tower-http", + "tracing", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2214,11 +2229,11 @@ dependencies = [ "emojis", "lazy_static", "libservice", - "lru", "macro-assets", "macro-sass", "markdown-it", "maud", + "page", "parking_lot", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 961e7db..1442fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ macro-sass = { path = "crates/macro/macro-sass" } assetserver = { path = "crates/lib/assetserver" } libservice = { path = "crates/lib/libservice" } toolbox = { path = "crates/lib/toolbox" } +page = { path = "crates/lib/page" } service-webpage = { path = "crates/service/service-webpage" } diff --git a/crates/lib/page/Cargo.toml b/crates/lib/page/Cargo.toml new file mode 100644 index 0000000..c3df260 --- /dev/null +++ b/crates/lib/page/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "page" +version = { workspace = true } +rust-version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[dependencies] +libservice = { workspace = true } + +axum = { workspace = true } +tracing = { workspace = true } +maud = { workspace = true } +chrono = { workspace = true } +parking_lot = { workspace = true } +serde = { workspace = true } +lru = { workspace = true } +tower-http = { workspace = true } diff --git a/crates/lib/page/src/lib.rs b/crates/lib/page/src/lib.rs new file mode 100644 index 0000000..8c8d0f0 --- /dev/null +++ b/crates/lib/page/src/lib.rs @@ -0,0 +1,8 @@ +mod page; +pub use page::*; + +mod requestcontext; +pub use requestcontext::*; + +mod server; +pub use server::*; diff --git a/crates/lib/page/src/page.rs b/crates/lib/page/src/page.rs new file mode 100644 index 0000000..82aff76 --- /dev/null +++ b/crates/lib/page/src/page.rs @@ -0,0 +1,105 @@ +use chrono::TimeDelta; +use maud::{Markup, Render, html}; +use serde::Deserialize; +use std::pin::Pin; + +use crate::RequestContext; + +// +// MARK: metadata +// + +#[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" {} + ) + } +} + +// +// 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, + &'a RequestContext, + ) -> 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, req_info: &RequestContext) -> Markup { + (self.generate_html)(self, req_info).await + } +} diff --git a/crates/lib/page/src/requestcontext.rs b/crates/lib/page/src/requestcontext.rs new file mode 100644 index 0000000..d1ee3d8 --- /dev/null +++ b/crates/lib/page/src/requestcontext.rs @@ -0,0 +1,60 @@ +use axum::http::HeaderMap; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RequestContext { + pub client_info: ClientInfo, +} + +// +// +// + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DeviceType { + Mobile, + Desktop, +} + +impl Default for DeviceType { + fn default() -> Self { + Self::Desktop + } +} + +// +// MARK: clientinfo +// + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ClientInfo { + /// This is an estimate, but it's probably good enough. + pub device_type: DeviceType, +} + +impl ClientInfo { + pub fn from_headers(headers: &HeaderMap) -> Self { + let ua = headers + .get("user-agent") + .and_then(|x| x.to_str().ok()) + .unwrap_or(""); + + let ch_mobile = headers + .get("Sec-CH-UA-Mobile") + .and_then(|x| x.to_str().ok()) + .unwrap_or(""); + + let mut device_type = None; + + if device_type.is_none() && ch_mobile.contains("1") { + device_type = Some(DeviceType::Mobile); + } + + if device_type.is_none() && ua.contains("Mobile") { + device_type = Some(DeviceType::Mobile); + } + + Self { + device_type: device_type.unwrap_or_default(), + } + } +} diff --git a/crates/service/service-webpage/src/page/mod.rs b/crates/lib/page/src/server.rs similarity index 50% rename from crates/service/service-webpage/src/page/mod.rs rename to crates/lib/page/src/server.rs index 2491e12..26174e5 100644 --- a/crates/service/service-webpage/src/page/mod.rs +++ b/crates/lib/page/src/server.rs @@ -1,7 +1,3 @@ -// -// MARK: metadata -// - use axum::{ Router, extract::{ConnectInfo, Path, State}, @@ -9,185 +5,17 @@ use axum::{ response::{IntoResponse, Response}, routing::get, }; -use chrono::{DateTime, TimeDelta, Utc}; +use chrono::{DateTime, Utc}; use libservice::ServiceConnectInfo; use lru::LruCache; -use markdown_it::Node; -use maud::{Markup, PreEscaped, Render, html}; +use maud::Markup; use parking_lot::Mutex; -use serde::Deserialize; use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant}; use tower_http::compression::{CompressionLayer, DefaultPredicate}; use tracing::{trace, warn}; -use crate::components::{ - md::{FrontMatter, Markdown}, - misc::Backlinks, -}; +use crate::{ClientInfo, RequestContext, page::Page}; -#[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 - .first() - .and_then(|x| x.cast::()) - .map(|x| serde_yaml::from_str::(&x.content)) - .map_or(Ok(None), |v| v.map(Some)) - } -} - -// -// MARK: page -// - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct RequestContext { - pub client_info: ClientInfo, -} - -// 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, - &'a RequestContext, - ) -> 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, req_info: &RequestContext) -> Markup { - (self.generate_html)(self, req_info).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(); - - 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 -// - -// 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 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. @@ -316,7 +144,7 @@ impl PageServer { let now = Utc::now(); let mut html_expires = None; - // Get from cache, if availablee + // Get from cache, if available if let Some((html, expires)) = state.html_cache.lock().get(&cache_key) && (*expires > now || state.never_rerender_on_request) { @@ -376,53 +204,3 @@ impl PageServer { .with_state(self) } } - -// -// MARK: UserAgent -// - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DeviceType { - Mobile, - Desktop, -} - -impl Default for DeviceType { - fn default() -> Self { - Self::Desktop - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ClientInfo { - /// This is an estimate, but it's probably good enough. - pub device_type: DeviceType, -} - -impl ClientInfo { - pub fn from_headers(headers: &HeaderMap) -> Self { - let ua = headers - .get("user-agent") - .and_then(|x| x.to_str().ok()) - .unwrap_or(""); - - let ch_mobile = headers - .get("Sec-CH-UA-Mobile") - .and_then(|x| x.to_str().ok()) - .unwrap_or(""); - - let mut device_type = None; - - if device_type.is_none() && ch_mobile.contains("1") { - device_type = Some(DeviceType::Mobile); - } - - if device_type.is_none() && ua.contains("Mobile") { - device_type = Some(DeviceType::Mobile); - } - - Self { - device_type: device_type.unwrap_or_default(), - } - } -} diff --git a/crates/service/service-webpage/Cargo.toml b/crates/service/service-webpage/Cargo.toml index 41536c8..b6f4b2f 100644 --- a/crates/service/service-webpage/Cargo.toml +++ b/crates/service/service-webpage/Cargo.toml @@ -12,6 +12,7 @@ libservice = { workspace = true } macro-assets = { workspace = true } macro-sass = { workspace = true } assetserver = { workspace = true } +page = { workspace = true } axum = { workspace = true } tracing = { workspace = true } @@ -25,6 +26,5 @@ lazy_static = { workspace = true } serde_yaml = { workspace = true } serde = { workspace = true } reqwest = { workspace = true } -lru = { workspace = true } tower-http = { workspace = true } tokio = { workspace = true } diff --git a/crates/service/service-webpage/src/components/md.rs b/crates/service/service-webpage/src/components/md.rs index c857059..46548e6 100644 --- a/crates/service/service-webpage/src/components/md.rs +++ b/crates/service/service-webpage/src/components/md.rs @@ -3,11 +3,13 @@ use markdown_it::parser::block::{BlockRule, BlockState}; use markdown_it::parser::core::Root; use markdown_it::parser::inline::{InlineRule, InlineState}; use markdown_it::{MarkdownIt, Node, NodeValue, Renderer}; -use maud::{Markup, PreEscaped, Render}; +use maud::{Markup, PreEscaped, Render, html}; +use page::{Page, PageMetadata}; use std::str::FromStr; use crate::components::fa::FAIcon; use crate::components::mangle::{MangledBetaEmail, MangledGoogleEmail}; +use crate::components::misc::Backlinks; lazy_static! { static ref MdParser: MarkdownIt = { @@ -38,6 +40,58 @@ impl Markdown<'_> { } } +// +// MARK: helpers +// + +/// 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 meta_from_markdown(root_node: &Node) -> Result, serde_yaml::Error> { + root_node + .children + .first() + .and_then(|x| x.cast::()) + .map(|x| serde_yaml::from_str::(&x.content)) + .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, _| { + let html = html.clone(); + Box::pin(async move { + html! { + @if let Some(slug) = &page.meta.slug { + (Backlinks(&[("/", "home")], slug)) + } + + (html) + } + }) + }), + + ..Default::default() + } +} + // // MARK: extensions // diff --git a/crates/service/service-webpage/src/lib.rs b/crates/service/service-webpage/src/lib.rs index 2f7c8a7..27b140f 100644 --- a/crates/service/service-webpage/src/lib.rs +++ b/crates/service/service-webpage/src/lib.rs @@ -2,7 +2,6 @@ use axum::Router; use libservice::ToService; mod components; -mod page; mod pages; mod routes; diff --git a/crates/service/service-webpage/src/pages/handouts.rs b/crates/service/service-webpage/src/pages/handouts.rs index 672ea76..90e289b 100644 --- a/crates/service/service-webpage/src/pages/handouts.rs +++ b/crates/service/service-webpage/src/pages/handouts.rs @@ -7,16 +7,16 @@ use std::{ use assetserver::Asset; use chrono::{DateTime, TimeDelta, Utc}; use maud::{Markup, PreEscaped, html}; +use page::{DeviceType, Page, RequestContext}; use parking_lot::Mutex; use serde::Deserialize; use tracing::{debug, warn}; use crate::{ components::{ - md::Markdown, + md::{Markdown, meta_from_markdown}, misc::{Backlinks, FarLink}, }, - page::{DeviceType, Page, PageMetadata, RequestContext}, routes::assets::Image_Icon, }; @@ -190,9 +190,7 @@ pub fn handouts() -> Page { tokio::spawn(index.clone().autoget(Duration::from_secs(60 * 20))); #[expect(clippy::unwrap_used)] - let mut meta = PageMetadata::from_markdown_frontmatter(&md) - .unwrap() - .unwrap(); + let mut meta = meta_from_markdown(&md).unwrap().unwrap(); if meta.image.is_none() { meta.image = Some(Image_Icon::URL.to_owned()); diff --git a/crates/service/service-webpage/src/pages/index.rs b/crates/service/service-webpage/src/pages/index.rs index 900d272..1c60a3e 100644 --- a/crates/service/service-webpage/src/pages/index.rs +++ b/crates/service/service-webpage/src/pages/index.rs @@ -1,5 +1,6 @@ use assetserver::Asset; use maud::html; +use page::{Page, PageMetadata}; use crate::{ components::{ @@ -8,7 +9,6 @@ use crate::{ md::Markdown, misc::FarLink, }, - page::{Page, PageMetadata}, routes::assets::{Image_Cover, Image_Icon}, }; diff --git a/crates/service/service-webpage/src/pages/mod.rs b/crates/service/service-webpage/src/pages/mod.rs index e8ed186..d17d5a4 100644 --- a/crates/service/service-webpage/src/pages/mod.rs +++ b/crates/service/service-webpage/src/pages/mod.rs @@ -1,6 +1,7 @@ use assetserver::Asset; +use page::Page; -use crate::{page::Page, routes::assets::Image_Icon}; +use crate::{components::md::page_from_markdown, routes::assets::Image_Icon}; mod handouts; mod index; @@ -16,11 +17,11 @@ pub fn links() -> Page { http://www.3dprintmath.com/ */ - Page::from_markdown(include_str!("links.md"), Some(Image_Icon::URL.to_owned())) + page_from_markdown(include_str!("links.md"), Some(Image_Icon::URL.to_owned())) } pub fn betalupi() -> Page { - Page::from_markdown( + page_from_markdown( include_str!("betalupi.md"), Some(Image_Icon::URL.to_owned()), ) diff --git a/crates/service/service-webpage/src/routes/mod.rs b/crates/service/service-webpage/src/routes/mod.rs index 475da44..e1ea136 100644 --- a/crates/service/service-webpage/src/routes/mod.rs +++ b/crates/service/service-webpage/src/routes/mod.rs @@ -3,14 +3,10 @@ use std::{pin::Pin, sync::Arc}; use assetserver::Asset; use axum::Router; use maud::{DOCTYPE, Markup, PreEscaped, html}; +use page::{Page, PageServer, RequestContext}; use tracing::info; -use crate::{ - components::misc::FarLink, - page::{Page, PageServer, RequestContext}, - pages, - routes::assets::Styles_Main, -}; +use crate::{components::misc::FarLink, pages, routes::assets::Styles_Main}; pub mod assets;