Refactor
Some checks failed
CI / Check links (push) Failing after 31s
CI / Check typos (push) Successful in 52s
CI / Clippy (push) Successful in 1m11s
CI / Build and test (push) Failing after 1m12s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped

This commit is contained in:
2025-11-05 08:59:20 -08:00
parent 063ea165d1
commit 2ee3ad3898
14 changed files with 280 additions and 245 deletions

17
Cargo.lock generated
View File

@@ -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",

View File

@@ -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" }

View File

@@ -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 }

View File

@@ -0,0 +1,8 @@
mod page;
pub use page::*;
mod requestcontext;
pub use requestcontext::*;
mod server;
pub use server::*;

105
crates/lib/page/src/page.rs Normal file
View File

@@ -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<String>,
pub description: Option<String>,
pub image: Option<String>,
pub slug: Option<String>,
}
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<TimeDelta>,
/// A function that generates this page's html.
///
/// This should return the contents of this page's <body> tag,
/// or the contents of a wrapper element (defined in the page server struct).
///
/// This closure must never return `<html>` or `<head>`.
pub generate_html: Box<
dyn Send
+ Sync
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + '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
}
}

View File

@@ -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(),
}
}
}

View File

@@ -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<String>,
pub description: Option<String>,
pub image: Option<String>,
pub slug: Option<String>,
}
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<Option<PageMetadata>, serde_yaml::Error> {
root_node
.children
.first()
.and_then(|x| x.cast::<FrontMatter>())
.map(|x| serde_yaml::from_str::<PageMetadata>(&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<TimeDelta>,
/// A function that generates this page's html.
///
/// This should return the contents of this page's <body> tag,
/// or the contents of a wrapper element (defined in the page server struct).
///
/// This closure must never return `<html>` or `<head>`.
pub generate_html: Box<
dyn Send
+ Sync
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + '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<String>, default_image: Option<String>) -> 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(),
}
}
}

View File

@@ -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 }

View File

@@ -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<Option<PageMetadata>, serde_yaml::Error> {
root_node
.children
.first()
.and_then(|x| x.cast::<FrontMatter>())
.map(|x| serde_yaml::from_str::<PageMetadata>(&x.content))
.map_or(Ok(None), |v| v.map(Some))
}
pub fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> 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
//

View File

@@ -2,7 +2,6 @@ use axum::Router;
use libservice::ToService;
mod components;
mod page;
mod pages;
mod routes;

View File

@@ -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());

View File

@@ -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},
};

View File

@@ -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()),
)

View File

@@ -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;