page rewrite
Some checks failed
CI / Check typos (push) Failing after 9s
CI / Check links (push) Failing after 14s
CI / Clippy (push) Successful in 53s
CI / Build and test (push) Successful in 1m19s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped

This commit is contained in:
2025-11-15 23:01:37 -08:00
parent 04d98462dd
commit de6136fb31
22 changed files with 1462 additions and 729 deletions

374
Cargo.lock generated
View File

@@ -394,6 +394,12 @@ version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -693,6 +699,29 @@ dependencies = [
"typenum",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.108",
]
[[package]]
name = "deranged"
version = "0.5.5"
@@ -737,6 +766,26 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "diff"
version = "0.1.13"
@@ -812,6 +861,27 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dtoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "either"
version = "1.15.0"
@@ -883,6 +953,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "exr"
version = "1.73.0"
@@ -909,6 +989,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
@@ -961,12 +1047,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -986,6 +1066,31 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1002,6 +1107,17 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
@@ -1037,6 +1153,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1048,6 +1165,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.9"
@@ -1058,6 +1184,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width 0.2.2",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@@ -1171,11 +1306,6 @@ name = "hashbrown"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "heck"
@@ -1212,6 +1342,17 @@ dependencies = [
"utf8-width",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever",
"match_token",
]
[[package]]
name = "http"
version = "1.3.1"
@@ -1681,6 +1822,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -1727,21 +1874,18 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f"
dependencies = [
"hashbrown 0.16.0",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "macro-sass"
version = "0.0.1"
@@ -1760,7 +1904,7 @@ dependencies = [
"argparse",
"const_format",
"derivative",
"derive_more",
"derive_more 0.99.20",
"downcast-rs",
"entities",
"html-escape",
@@ -1774,6 +1918,28 @@ dependencies = [
"unicode-general-category",
]
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2085,6 +2251,26 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "ormc-scrape"
version = "0.0.1"
dependencies = [
"anyhow",
"axum",
"base64",
"clap",
"futures",
"reqwest",
"scraper",
"serde",
"serde_json",
"tempfile",
"tokio",
"toolbox",
"tracing",
"url",
]
[[package]]
name = "owo-colors"
version = "4.2.3"
@@ -2097,15 +2283,13 @@ version = "0.0.1"
dependencies = [
"axum",
"chrono",
"libservice",
"lru",
"maud",
"parking_lot",
"pixel-transform",
"serde",
"serde_urlencoded",
"tokio",
"toolbox",
"tower-http",
"tower",
"tracing",
]
@@ -2163,6 +2347,16 @@ dependencies = [
"phf_shared 0.13.1",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
@@ -2283,6 +2477,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@@ -2829,6 +3029,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.34"
@@ -2897,6 +3110,40 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "selectors"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6"
dependencies = [
"bitflags",
"cssparser",
"derive_more 2.0.1",
"fxhash",
"log",
"new_debug_unreachable",
"phf 0.11.3",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.27"
@@ -3012,9 +3259,19 @@ dependencies = [
"tokio",
"toml 0.9.8",
"toolbox",
"tower-http",
"tracing",
]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3130,6 +3387,31 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3329,6 +3611,30 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "term"
version = "0.7.0"
@@ -3914,6 +4220,12 @@ dependencies = [
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.7"
@@ -4134,6 +4446,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpage"
version = "0.0.1"

View File

@@ -80,6 +80,8 @@ service-webpage = { path = "crates/service/service-webpage" }
#
axum = { version = "0.8.6", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace", "compression-full"] }
tower = { version = "0.5.2" }
serde_urlencoded = { version = "0.7.1" }
utoipa = "5.4.0"
utoipa-swagger-ui = { version = "9.0.2", features = [
"axum",
@@ -93,6 +95,7 @@ emojis = "0.8.0"
reqwest = { version = "0.12.24", default-features = false, features = [
"http2",
"rustls-tls",
"rustls-tls-webpki-roots", # Need to recompile to update
"cookies",
"gzip",
"stream",
@@ -144,6 +147,9 @@ lru = "0.16.2"
parking_lot = "0.12.5"
lazy_static = "1.5.0"
image = "0.25.8"
scraper = "0.24.0"
futures = "0.3.31"
tempfile = "3.23.0"
# md_* test utilities
prettydiff = "0.9.0"

View File

@@ -9,7 +9,6 @@ workspace = true
[dependencies]
toolbox = { workspace = true }
libservice = { workspace = true }
pixel-transform = { workspace = true }
axum = { workspace = true }
@@ -17,7 +16,6 @@ tokio = { 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 }
tower = { workspace = true }
serde_urlencoded = { workspace = true }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
htmx.defineExtension('json-enc', {
onEvent: function (name, evt) {
if (name === "htmx:configRequest") {
evt.detail.headers['Content-Type'] = "application/json";
}
},
encodeParameters: function (xhr, parameters, elt) {
xhr.overrideMimeType('text/json');
return (JSON.stringify(parameters));
}
});

View File

@@ -1,8 +1,25 @@
mod servable;
pub use servable::*;
//! A web stack for embedded uis.
//!
//! Featuring:
//! - htmx
//! - axum
//! - rust
//! - and maud
mod requestcontext;
pub use requestcontext::*;
pub mod servable;
mod server;
pub use server::*;
mod types;
pub use types::*;
mod route;
pub use route::*;
pub const HTMX_2_0_8: servable::StaticAsset = servable::StaticAsset {
bytes: include_str!("../htmx/htmx-2.0.8.min.js").as_bytes(),
mime: toolbox::mime::MimeType::Javascript,
};
pub const EXT_JSON_1_19_12: servable::StaticAsset = servable::StaticAsset {
bytes: include_str!("../htmx/json-enc-1.9.12.js").as_bytes(),
mime: toolbox::mime::MimeType::Javascript,
};

View File

@@ -0,0 +1,275 @@
use axum::{
Router,
body::Body,
http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header},
response::{IntoResponse, Response},
};
use chrono::TimeDelta;
use std::{
collections::{BTreeMap, HashMap},
convert::Infallible,
net::SocketAddr,
pin::Pin,
sync::Arc,
task::{Context, Poll},
time::Instant,
};
use toolbox::mime::MimeType;
use tower::Service;
use tracing::trace;
use crate::{ClientInfo, RenderContext, Rendered, RenderedBody, servable::Servable};
struct Default404 {}
impl Servable for Default404 {
fn head<'a>(
&'a self,
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async {
return Rendered {
code: StatusCode::NOT_FOUND,
body: (),
ttl: Some(TimeDelta::days(1)),
immutable: true,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
};
})
}
fn render<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
Box::pin(async { self.head(ctx).await.with_body(RenderedBody::Empty) })
}
}
/// A set of related [Servable]s under one route.
///
/// Use as follows:
/// ```ignore
///
/// // Add compression, for example.
/// // Also consider CORS and timeout.
/// let compression: CompressionLayer = CompressionLayer::new()
/// .br(true)
/// .deflate(true)
/// .gzip(true)
/// .zstd(true)
/// .compress_when(DefaultPredicate::new());
///
/// let route = ServableRoute::new()
/// .add_page(
/// "/page",
/// StaticAsset {
/// bytes: "I am a page".as_bytes(),
/// mime: MimeType::Text,
/// },
/// );
///
/// Router::new()
/// .nest_service("/", route)
/// .layer(compression.clone());
/// ```
#[derive(Clone)]
pub struct ServableRoute {
pages: Arc<HashMap<String, Arc<dyn Servable>>>,
notfound: Arc<dyn Servable>,
}
impl ServableRoute {
pub fn new() -> Self {
Self {
pages: Arc::new(HashMap::new()),
notfound: Arc::new(Default404 {}),
}
}
/// Set this server's "not found" page
pub fn with_404<S: Servable + 'static>(mut self, page: S) -> Self {
self.notfound = Arc::new(page);
self
}
/// Add a page to this server at the given route.
/// - panics if route does not start with a `/`, ends with a `/`, or contains `//`.
/// - urls are normalized, routes that violate this condition will never be served.
/// - `/` is an exception, it is valid.
/// - panics if called after this service is started
/// - overwrites existing pages
pub fn add_page<S: Servable + 'static>(mut self, route: impl Into<String>, page: S) -> Self {
let route = route.into();
if !route.starts_with("/") {
panic!("route must start with /")
};
if route.ends_with("/") && route != "/" {
panic!("route must not end with /")
};
if route.contains("//") {
panic!("route must not contain //")
};
#[expect(clippy::expect_used)]
Arc::get_mut(&mut self.pages)
.expect("add_pages called after service was started")
.insert(route, Arc::new(page));
self
}
/// Convenience method.
/// Turns this service into a router.
///
/// Equivalent to:
/// ```ignore
/// Router::new().fallback_service(self)
/// ```
pub fn into_router<T: Clone + Send + Sync + 'static>(self) -> Router<T> {
Router::new().fallback_service(self)
}
}
//
// MARK: impl Service
//
impl Service<Request<Body>> for ServableRoute {
type Response = Response;
type Error = Infallible;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, req: Request<Body>) -> Self::Future {
if req.method() != Method::GET && req.method() != Method::HEAD {
let mut headers = HeaderMap::with_capacity(1);
headers.insert(header::ACCEPT, HeaderValue::from_static("GET,HEAD"));
return Box::pin(async {
Ok((StatusCode::METHOD_NOT_ALLOWED, headers).into_response())
});
}
let pages = self.pages.clone();
let notfound = self.notfound.clone();
Box::pin(async move {
let addr = req.extensions().get::<SocketAddr>().copied();
let route = req.uri().path().to_owned();
let headers = req.headers().clone();
let query: BTreeMap<String, String> =
serde_urlencoded::from_str(req.uri().query().unwrap_or("")).unwrap_or_default();
let start = Instant::now();
let client_info = ClientInfo::from_headers(&headers);
let ua = headers
.get("user-agent")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
trace!(
message = "Serving route",
route,
addr = ?addr,
user_agent = ua,
device_type = ?client_info.device_type
);
// Normalize url with redirect
if (route.ends_with('/') && route != "/") || route.contains("//") {
let mut new_route = route.clone();
while new_route.contains("//") {
new_route = new_route.replace("//", "/");
}
let new_route = new_route.trim_matches('/');
trace!(
message = "Redirecting",
route,
new_route,
addr = ?addr,
user_agent = ua,
device_type = ?client_info.device_type
);
let mut headers = HeaderMap::with_capacity(1);
match HeaderValue::from_str(&format!("/{new_route}")) {
Ok(x) => headers.append(header::LOCATION, x),
Err(_) => return Ok(StatusCode::BAD_REQUEST.into_response()),
};
return Ok((StatusCode::PERMANENT_REDIRECT, headers).into_response());
}
let ctx = RenderContext {
client_info,
route,
query,
};
let page = pages.get(&ctx.route).unwrap_or(&notfound);
let mut rend = match req.method() == Method::HEAD {
true => page.head(&ctx).await.with_body(RenderedBody::Empty),
false => page.render(&ctx).await,
};
// Tweak headers
{
if !rend.headers.contains_key(header::CACHE_CONTROL) {
let max_age = rend.ttl.map(|x| x.num_seconds()).unwrap_or(1).max(1);
let mut value = String::new();
if rend.immutable {
value.push_str("immutable, ");
}
value.push_str("public, ");
value.push_str(&format!("max-age={}, ", max_age));
#[expect(clippy::unwrap_used)]
rend.headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
);
}
if !rend.headers.contains_key("Accept-CH") {
rend.headers
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
}
if !rend.headers.contains_key(header::CONTENT_TYPE)
&& let Some(mime) = &rend.mime
{
#[expect(clippy::unwrap_used)]
rend.headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&mime.to_string()).unwrap(),
);
}
}
trace!(
message = "Served route",
route = ctx.route,
addr = ?addr,
user_agent = ua,
device_type = ?client_info.device_type,
time_ns = start.elapsed().as_nanos()
);
Ok(match rend.body {
RenderedBody::Markup(m) => (rend.code, rend.headers, m.0).into_response(),
RenderedBody::Static(d) => (rend.code, rend.headers, d).into_response(),
RenderedBody::Bytes(d) => (rend.code, rend.headers, d).into_response(),
RenderedBody::String(s) => (rend.code, rend.headers, s).into_response(),
RenderedBody::Empty => (rend.code, rend.headers).into_response(),
})
})
}
}

View File

@@ -5,7 +5,7 @@ use std::{pin::Pin, str::FromStr};
use toolbox::mime::MimeType;
use tracing::{error, trace};
use crate::{Rendered, RenderedBody, RequestContext, Servable};
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
pub struct StaticAsset {
pub bytes: &'static [u8],
@@ -13,10 +13,69 @@ pub struct StaticAsset {
}
impl Servable for StaticAsset {
fn head<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async {
let ttl = Some(TimeDelta::days(30));
let is_image = TransformerChain::mime_is_image(&self.mime);
let transform = match (is_image, ctx.query.get("t")) {
(false, _) | (_, None) => None,
(true, Some(x)) => match TransformerChain::from_str(x) {
Ok(x) => Some(x),
Err(_err) => {
return Rendered {
code: StatusCode::BAD_REQUEST,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
},
};
match transform {
Some(transform) => {
return Rendered {
code: StatusCode::OK,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(
transform
.output_mime(&self.mime)
.unwrap_or(self.mime.clone()),
),
};
}
None => {
return Rendered {
code: StatusCode::OK,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(self.mime.clone()),
};
}
}
})
}
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
Box::pin(async {
let ttl = Some(TimeDelta::days(30));

View File

@@ -1,3 +1,26 @@
pub mod asset;
pub mod page;
pub mod redirect;
mod asset;
pub use asset::*;
mod page;
pub use page::*;
mod redirect;
pub use redirect::*;
/// Something that may be served over http.
pub trait Servable: Send + Sync {
/// Return the same response as [Servable::render], but with an empty body.
/// Used to respond to `HEAD` requests.
fn head<'a>(
&'a self,
ctx: &'a crate::RenderContext,
) -> std::pin::Pin<Box<dyn Future<Output = crate::Rendered<()>> + 'a + Send + Sync>>;
/// Render this page
fn render<'a>(
&'a self,
ctx: &'a crate::RenderContext,
) -> std::pin::Pin<
Box<dyn Future<Output = crate::Rendered<crate::RenderedBody>> + 'a + Send + Sync>,
>;
}

View File

@@ -1,11 +1,11 @@
use axum::http::{HeaderMap, StatusCode};
use chrono::TimeDelta;
use maud::{Markup, Render, html};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use serde::Deserialize;
use std::pin::Pin;
use std::{pin::Pin, sync::Arc};
use toolbox::mime::MimeType;
use crate::{Rendered, RenderedBody, RequestContext, Servable};
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
//
// MARK: metadata
@@ -17,7 +17,6 @@ pub struct PageMetadata {
pub author: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
pub backlinks: Option<bool>,
}
impl Default for PageMetadata {
@@ -27,42 +26,15 @@ impl Default for PageMetadata {
author: None,
description: None,
image: None,
backlinks: 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
#[derive(Clone)]
pub struct Page {
pub meta: PageMetadata,
pub immutable: bool,
@@ -79,47 +51,101 @@ pub struct Page {
/// 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<
pub generate_html: Arc<
dyn Send
+ Sync
+ 'static
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
&'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>>,
>,
pub response_code: StatusCode,
pub scripts_inline: Vec<String>,
pub scripts_linked: Vec<String>,
pub styles_linked: Vec<String>,
pub styles_inline: Vec<String>,
/// `name`, `content` for extra `<meta>` tags
pub extra_meta: Vec<(String, String)>,
}
impl Default for Page {
fn default() -> Self {
Page {
// No cache by default
html_ttl: None,
immutable: false,
meta: Default::default(),
html_ttl: Some(TimeDelta::days(1)),
generate_html: Box::new(|_, _| Box::pin(async { html!() })),
immutable: true,
generate_html: Arc::new(|_, _| Box::pin(async { html!() })),
response_code: StatusCode::OK,
scripts_inline: Vec::new(),
scripts_linked: Vec::new(),
styles_inline: Vec::new(),
styles_linked: Vec::new(),
extra_meta: Vec::new(),
}
}
}
impl Page {
pub async fn generate_html(&self, ctx: &RequestContext) -> Markup {
pub async fn generate_html(&self, ctx: &RenderContext) -> Markup {
(self.generate_html)(self, ctx).await
}
pub fn immutable(mut self, immutable: bool) -> Self {
self.immutable = immutable;
self
}
pub fn html_ttl(mut self, html_ttl: Option<TimeDelta>) -> Self {
self.html_ttl = html_ttl;
self
}
pub fn response_code(mut self, response_code: StatusCode) -> Self {
self.response_code = response_code;
self
}
pub fn with_script_inline(mut self, script: impl Into<String>) -> Self {
self.scripts_inline.push(script.into());
self
}
pub fn with_script_linked(mut self, url: impl Into<String>) -> Self {
self.scripts_linked.push(url.into());
self
}
pub fn with_style_inline(mut self, style: impl Into<String>) -> Self {
self.styles_inline.push(style.into());
self
}
pub fn with_style_linked(mut self, url: impl Into<String>) -> Self {
self.styles_linked.push(url.into());
self
}
pub fn with_extra_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_meta.push((key.into(), value.into()));
self
}
}
impl Servable for Page {
fn render<'a>(
fn head<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async {
let html = self.generate_html(ctx).await;
return Rendered {
code: self.response_code,
body: RenderedBody::Markup(html),
body: (),
ttl: self.html_ttl,
immutable: self.immutable,
headers: HeaderMap::new(),
@@ -127,4 +153,157 @@ impl Servable for Page {
};
})
}
fn render<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
Box::pin(async {
let inner_html = self.generate_html(ctx).await;
let html = html! {
(DOCTYPE)
html {
head {
meta charset="UTF-8";
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";
@for (name, content) in &self.extra_meta {
meta name=(name) content=(content);
}
//
// Metadata
//
title { (PreEscaped(self.meta.title.clone())) }
meta property="og:site_name" content=(self.meta.title);
meta name="title" content=(self.meta.title);
meta property="og:title" content=(self.meta.title);
meta property="twitter:title" content=(self.meta.title);
@if let Some(author) = &self.meta.author {
meta name="author" content=(author);
}
@if let Some(desc) = &self.meta.description {
meta name="description" content=(desc);
meta property="og:description" content=(desc);
meta property="twitter:description" content=(desc);
}
@if let Some(image) = &self.meta.image {
meta content=(image) property="og:image";
link rel="shortcut icon" href=(image) type="image/x-icon";
}
//
// Scripts & styles
//
@for script in &self.scripts_linked {
script src=(script) {}
}
@for style in &self.styles_linked {
link rel="stylesheet" type="text/css" href=(style);
}
@for script in &self.scripts_inline {
script { (PreEscaped(script)) }
}
@for style in &self.styles_inline {
style { (PreEscaped(style)) }
}
}
body { main { (inner_html) } }
}
};
return self.head(ctx).await.with_body(RenderedBody::Markup(html));
})
}
}
//
// MARK: template
//
pub struct PageTemplate {
pub immutable: bool,
pub html_ttl: Option<TimeDelta>,
pub response_code: StatusCode,
pub scripts_inline: &'static [&'static str],
pub scripts_linked: &'static [&'static str],
pub styles_inline: &'static [&'static str],
pub styles_linked: &'static [&'static str],
pub extra_meta: &'static [(&'static str, &'static str)],
}
impl Default for PageTemplate {
fn default() -> Self {
Self::const_default()
}
}
impl PageTemplate {
pub const fn const_default() -> Self {
Self {
html_ttl: Some(TimeDelta::days(1)),
immutable: true,
response_code: StatusCode::OK,
scripts_inline: &[],
scripts_linked: &[],
styles_inline: &[],
styles_linked: &[],
extra_meta: &[],
}
}
/// Create a new page using this template,
/// with the given metadata and renderer.
pub fn derive<
R: Send
+ Sync
+ 'static
+ for<'a> Fn(
&'a Page,
&'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>>,
>(
&self,
meta: PageMetadata,
generate_html: R,
) -> Page {
Page {
meta,
immutable: self.immutable,
html_ttl: self.html_ttl,
response_code: self.response_code,
scripts_inline: self
.scripts_inline
.iter()
.map(|x| (*x).to_owned())
.collect(),
scripts_linked: self
.scripts_linked
.iter()
.map(|x| (*x).to_owned())
.collect(),
styles_inline: self.styles_inline.iter().map(|x| (*x).to_owned()).collect(),
styles_linked: self.styles_linked.iter().map(|x| (*x).to_owned()).collect(),
extra_meta: self
.extra_meta
.iter()
.map(|(a, b)| ((*a).to_owned(), (*b).to_owned()))
.collect(),
generate_html: Arc::new(generate_html),
}
}
}

View File

@@ -5,7 +5,7 @@ use axum::http::{
header::{self, InvalidHeaderValue},
};
use crate::{Rendered, RenderedBody, RequestContext, Servable};
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
pub struct Redirect {
to: HeaderValue,
@@ -20,10 +20,10 @@ impl Redirect {
}
impl Servable for Redirect {
fn render<'a>(
fn head<'a>(
&'a self,
_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async {
let mut headers = HeaderMap::with_capacity(1);
headers.append(header::LOCATION, self.to.clone());
@@ -31,11 +31,18 @@ impl Servable for Redirect {
return Rendered {
code: StatusCode::PERMANENT_REDIRECT,
headers,
body: RenderedBody::Empty,
body: (),
ttl: None,
immutable: true,
mime: None,
};
})
}
fn render<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
Box::pin(async { self.head(ctx).await.with_body(RenderedBody::Empty) })
}
}

View File

@@ -1,324 +0,0 @@
use axum::{
Router,
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use chrono::{DateTime, TimeDelta, Utc};
use libservice::ServiceConnectInfo;
use lru::LruCache;
use maud::Markup;
use parking_lot::Mutex;
use std::{
collections::{BTreeMap, HashMap},
num::NonZero,
pin::Pin,
sync::Arc,
time::Instant,
};
use toolbox::mime::MimeType;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::trace;
use crate::{ClientInfo, RequestContext};
#[derive(Clone)]
pub enum RenderedBody {
Markup(Markup),
Static(&'static [u8]),
Bytes(Vec<u8>),
String(String),
Empty,
}
#[derive(Clone)]
pub struct Rendered {
pub code: StatusCode,
pub headers: HeaderMap,
pub body: RenderedBody,
pub mime: Option<MimeType>,
/// How long to cache this response.
/// If none, don't cache.
pub ttl: Option<TimeDelta>,
pub immutable: bool,
}
pub trait Servable: Send + Sync {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
}
pub struct Default404 {}
impl Servable for Default404 {
fn render<'a>(
&'a self,
_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
Box::pin(async {
return Rendered {
code: StatusCode::NOT_FOUND,
body: RenderedBody::String("page not found".into()),
ttl: Some(TimeDelta::days(1)),
immutable: true,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
};
})
}
}
//
// 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: Mutex<HashMap<String, Arc<dyn Servable>>>,
notfound: Mutex<Arc<dyn Servable>>,
/// Map of `{ route: (page data, expire time) }`
///
/// We use an LruCache for bounded memory usage.
page_cache: Mutex<LruCache<RequestContext, (Rendered, DateTime<Utc>)>>,
}
impl PageServer {
pub fn new() -> Arc<Self> {
#[expect(clippy::unwrap_used)]
let cache_size = NonZero::new(128).unwrap();
Arc::new(Self {
pages: Mutex::new(HashMap::new()),
page_cache: Mutex::new(LruCache::new(cache_size)),
never_rerender_on_request: true,
notfound: Mutex::new(Arc::new(Default404 {})),
})
}
/// Set this server's "not found" page
pub fn with_404<S: Servable + 'static>(&self, page: S) -> &Self {
*self.notfound.lock() = Arc::new(page);
self
}
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
#[expect(clippy::expect_used)]
let route = route
.into()
.strip_prefix("/")
.expect("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,
ctx: RequestContext,
) -> (Rendered, Option<DateTime<Utc>>) {
let now = Utc::now();
let start = Instant::now();
let page = match self.pages.lock().get(route) {
Some(x) => x.clone(),
None => self.notfound.lock().clone(),
};
trace!(
message = "Rendering page",
route = route.to_owned(),
reason,
lock_time_ms = start.elapsed().as_millis()
);
let rendered = page.render(&ctx).await;
let mut expires = None;
if let Some(ttl) = rendered.ttl {
expires = Some(now + ttl);
self.page_cache
.lock()
.put(ctx, (rendered.clone(), now + ttl));
}
let elapsed = start.elapsed().as_millis();
trace!(
message = "Rendered page",
route = route.to_owned(),
reason,
time_ms = elapsed
);
return (rendered, expires);
}
async fn handler(
Path(route): Path<String>,
Query(query): Query<BTreeMap<String, String>>,
State(state): State<Arc<Self>>,
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap,
) -> Response {
let start = Instant::now();
let client_info = ClientInfo::from_headers(&headers);
let ua = headers
.get("user-agent")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
trace!(
message = "Serving route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
// Normalize url with redirect
if route.ends_with('/') || route.contains("//") || route.starts_with('/') {
let mut new_route = route.clone();
while new_route.contains("//") {
new_route = new_route.replace("//", "/");
}
let new_route = new_route.trim_matches('/');
trace!(
message = "Redirecting",
route,
new_route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
let mut headers = HeaderMap::with_capacity(2);
let new_route = match HeaderValue::from_str(&format!("/{new_route}")) {
Ok(x) => x,
Err(_) => {
// Be extra careful, this is user-provided data
return StatusCode::BAD_REQUEST.into_response();
}
};
headers.append(header::LOCATION, new_route);
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
return (StatusCode::PERMANENT_REDIRECT, headers).into_response();
}
let ctx = RequestContext {
client_info,
route: format!("/{route}"),
query,
};
let now = Utc::now();
let mut html_expires = None;
let mut cached = true;
// Get from cache, if available
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() {
cached = false;
html_expires = Some(state.render_page("request", &route, ctx).await);
}
#[expect(clippy::unwrap_used)]
let (mut html, expires) = html_expires.unwrap();
if !html.headers.contains_key(header::CACHE_CONTROL) {
let max_age = match expires {
Some(expires) => (expires - now).num_seconds().max(1),
None => 1,
};
let mut value = String::new();
if html.immutable {
value.push_str("immutable, ");
}
value.push_str("public, ");
value.push_str(&format!("max-age={}, ", max_age));
#[expect(clippy::unwrap_used)]
html.headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
);
}
if !html.headers.contains_key("Accept-CH") {
html.headers
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
}
if let Some(mime) = &html.mime {
#[expect(clippy::unwrap_used)]
html.headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&mime.to_string()).unwrap(),
);
}
trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos()
);
return match html.body {
RenderedBody::Markup(markup) => (html.code, html.headers, markup.0).into_response(),
RenderedBody::Static(data) => (html.code, html.headers, data).into_response(),
RenderedBody::Bytes(data) => (html.code, html.headers, data).into_response(),
RenderedBody::String(s) => (html.code, html.headers, s).into_response(),
RenderedBody::Empty => (html.code, html.headers).into_response(),
};
}
pub fn into_router(self: Arc<Self>) -> Router<()> {
let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
Router::new()
.route(
"/",
get(|state, query, conn, headers| async {
Self::handler(Path(String::new()), query, state, conn, headers).await
}),
)
.route("/{*path}", get(Self::handler))
.layer(compression)
.with_state(self)
}
}

View File

@@ -1,16 +1,66 @@
use axum::http::{HeaderMap, StatusCode};
use chrono::TimeDelta;
use maud::Markup;
use std::collections::BTreeMap;
use toolbox::mime::MimeType;
use axum::http::HeaderMap;
//
// MARK: rendered
//
#[derive(Clone)]
pub enum RenderedBody {
Markup(Markup),
Static(&'static [u8]),
Bytes(Vec<u8>),
String(String),
Empty,
}
pub trait RenderedBodyType {}
impl RenderedBodyType for () {}
impl RenderedBodyType for RenderedBody {}
#[derive(Clone)]
pub struct Rendered<T: RenderedBodyType> {
pub code: StatusCode,
pub headers: HeaderMap,
pub body: T,
pub mime: Option<MimeType>,
/// How long to cache this response.
/// If none, don't cache.
pub ttl: Option<TimeDelta>,
pub immutable: bool,
}
impl Rendered<()> {
/// Turn this [Rendered] into a [Rendered] with a body.
pub fn with_body(self, body: RenderedBody) -> Rendered<RenderedBody> {
Rendered {
code: self.code,
headers: self.headers,
body,
mime: self.mime,
ttl: self.ttl,
immutable: self.immutable,
}
}
}
//
// MARK: context
//
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestContext {
pub struct RenderContext {
pub client_info: ClientInfo,
pub route: String,
pub query: BTreeMap<String, String>,
}
//
//
// MARK: clientinfo
//
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -25,10 +75,6 @@ impl Default for DeviceType {
}
}
//
// MARK: clientinfo
//
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ClientInfo {
/// This is an estimate, but it's probably good enough.

View File

@@ -38,6 +38,20 @@ impl TransformerChain {
return image;
}
pub fn output_mime(&self, input_mime: &MimeType) -> Option<MimeType> {
let mime = self
.steps
.last()
.and_then(|x| match x {
TransformerEnum::Format { format } => Some(MimeType::from(format.to_mime_type())),
_ => None,
})
.unwrap_or(input_mime.clone());
let fmt = ImageFormat::from_mime_type(mime.to_string());
fmt.map(|_| mime)
}
pub fn transform_bytes(
&self,
image_bytes: &[u8],

View File

@@ -251,6 +251,23 @@ impl From<&MimeType> for String {
// MARK: fromstr
//
impl MimeType {
/// Parse a mimetype from a string that may contain
/// whitespace or ";" parameters.
///
/// Parameters are discarded, write your own parser if you need them.
pub fn from_header(s: &str) -> Result<Self, <Self as FromStr>::Err> {
let s = s.trim();
let semi = s.find(';').unwrap_or(s.len());
let space = s.find(' ').unwrap_or(s.len());
let limit = semi.min(space);
let s = &s[0..limit];
let s = s.trim();
return Self::from_str(s);
}
}
impl FromStr for MimeType {
type Err = std::convert::Infallible;
@@ -687,4 +704,108 @@ impl MimeType {
Self::Xul => Some("xul"),
}
}
//
// MARK: is_text
//
/// Returns true if this MIME type is always plain text.
pub fn is_text(&self) -> bool {
match self {
// Text types
Self::Text => true,
Self::Css => true,
Self::Csv => true,
Self::Html => true,
Self::Javascript => true,
Self::Json => true,
Self::JsonLd => true,
Self::Xml => true,
Self::Svg => true,
Self::Ics => true,
Self::Xhtml => true,
// Script types
Self::Csh => true,
Self::Php => true,
Self::Sh => true,
// All other types are not plain text
Self::Other(_) => false,
Self::Blob => false,
// Audio
Self::Aac => false,
Self::Flac => false,
Self::Midi => false,
Self::Mp3 => false,
Self::Oga => false,
Self::Opus => false,
Self::Wav => false,
Self::Weba => false,
// Video
Self::Avi => false,
Self::Mp4 => false,
Self::Mpeg => false,
Self::Ogv => false,
Self::Ts => false,
Self::WebmVideo => false,
Self::ThreeGp => false,
Self::ThreeG2 => false,
// Images
Self::Apng => false,
Self::Avif => false,
Self::Bmp => false,
Self::Gif => false,
Self::Ico => false,
Self::Jpg => false,
Self::Png => false,
Self::Qoi => false,
Self::Tiff => false,
Self::Webp => false,
// Documents
Self::Pdf => false,
Self::Rtf => false,
// Archives
Self::Arc => false,
Self::Bz => false,
Self::Bz2 => false,
Self::Gz => false,
Self::Jar => false,
Self::Ogg => false,
Self::Rar => false,
Self::SevenZ => false,
Self::Tar => false,
Self::Zip => false,
// Fonts
Self::Eot => false,
Self::Otf => false,
Self::Ttf => false,
Self::Woff => false,
Self::Woff2 => false,
// Applications
Self::Abiword => false,
Self::Azw => false,
Self::Cda => false,
Self::Doc => false,
Self::Docx => false,
Self::Epub => false,
Self::Mpkg => false,
Self::Odp => false,
Self::Ods => false,
Self::Odt => false,
Self::Ppt => false,
Self::Pptx => false,
Self::Vsd => false,
Self::Xls => false,
Self::Xlsx => false,
Self::Xul => false,
}
}
}

View File

@@ -28,3 +28,4 @@ toml = { workspace = true }
serde = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }
tower-http = { workspace = true }

View File

@@ -1,9 +1,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::RequestContext;
use page::page::{Page, PageMetadata};
use maud::{Markup, PreEscaped, Render};
use page::servable::PageMetadata;
use crate::components::md::emote::InlineEmote;
use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter};
@@ -86,10 +85,6 @@ 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
@@ -101,33 +96,3 @@ pub fn meta_from_markdown(root_node: &Node) -> Result<Option<PageMetadata>, toml
.map(|x| toml::from_str::<PageMetadata>(&x.content))
.map_or(Ok(None), |v| v.map(Some))
}
pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> {
let mut last = None;
let mut backlinks = vec![("/", "home")];
if page.meta.backlinks.unwrap_or(false) {
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
last = segments.pop();
let mut end = 0;
for s in segments {
end += s.len();
backlinks.push((&ctx.route[0..=end], s));
end += 1; // trailing slash
}
}
last.map(|last| {
html! {
div {
@for (url, text) in backlinks {
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
"/"
}
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
}
}
})
}

View File

@@ -6,18 +6,17 @@ use std::{
use chrono::{DateTime, TimeDelta, Utc};
use maud::{Markup, PreEscaped, html};
use page::{DeviceType, RequestContext, page::Page};
use page::{DeviceType, RenderContext, servable::Page};
use parking_lot::Mutex;
use reqwest::StatusCode;
use serde::Deserialize;
use tracing::{debug, warn};
use crate::{
components::{
md::{Markdown, backlinks, meta_from_markdown},
md::{Markdown, meta_from_markdown},
misc::FarLink,
},
pages::page_wrapper,
pages::{MAIN_TEMPLATE, backlinks, footer},
};
#[derive(Debug, Deserialize)]
@@ -114,11 +113,7 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
return Ok(res);
}
fn build_list_for_group(
handouts: &[HandoutEntry],
group: &str,
req_ctx: &RequestContext,
) -> Markup {
fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &RenderContext) -> Markup {
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
if mobile {
@@ -198,74 +193,79 @@ pub fn handouts() -> Page {
let html = PreEscaped(md.render());
Page {
meta,
html_ttl: Some(TimeDelta::seconds(300)),
immutable: false,
response_code: StatusCode::OK,
generate_html: Box::new(move |page, ctx| {
let html = html.clone(); // TODO: find a way to not clone here
MAIN_TEMPLATE
.derive(meta, move |page, ctx| {
let html = html.clone();
let index = index.clone();
Box::pin(async move {
let handouts = index.get().await;
let fallback = html! {
span style="color:var(--yellow)" {
"Could not load handouts, something broke."
}
" "
(
FarLink(
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
"Try this direct link."
)
)
};
let warmups = match &*handouts {
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", ctx),
Err(error) => {
warn!("Could not load handout index: {error:?}");
fallback.clone()
}
};
let advanced = match &*handouts {
Ok(handouts) => build_list_for_group(handouts, "Advanced", ctx),
Err(_) => fallback,
};
let inner = html! {
@if let Some(backlinks) = backlinks(page, ctx) {
(backlinks)
}
(html)
(Markdown(concat!(
"## Warm-Ups",
"\n\n",
"Students never show up on time. Some come early, some come late. Warm-ups ",
"are my solution to this problem: we hand these out as students walk in, ",
"giving them something to do until we can start the lesson.",
)))
(warmups)
br {}
(Markdown(concat!(
"## Advanced",
"\n\n",
"The highest level of the ORMC, and the group I spend most of my time with. ",
"Students in ORMC Advanced are in high school, which means ",
"they're ~14-18 years old.",
)))
(advanced)
br {}
};
page_wrapper(&page.meta, inner, true).await
})
}),
}
render(html, index, page, ctx)
})
.html_ttl(Some(TimeDelta::seconds(300)))
}
fn render<'a>(
html: Markup,
index: Arc<CachedRequest<Result<Vec<HandoutEntry>, reqwest::Error>>>,
_page: &'a Page,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
Box::pin(async move {
let handouts = index.get().await;
let fallback = html! {
span style="color:var(--yellow)" {
"Could not load handouts, something broke."
}
" "
(
FarLink(
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
"Try this direct link."
)
)
};
let warmups = match &*handouts {
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", ctx),
Err(error) => {
warn!("Could not load handout index: {error:?}");
fallback.clone()
}
};
let advanced = match &*handouts {
Ok(handouts) => build_list_for_group(handouts, "Advanced", ctx),
Err(_) => fallback,
};
html! {
div class="wrapper" style="margin-top:3ex;" {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks)
}
(html)
(Markdown(concat!(
"## Warm-Ups",
"\n\n",
"Students never show up on time. Some come early, some come late. Warm-ups ",
"are my solution to this problem: we hand these out as students walk in, ",
"giving them something to do until we can start the lesson.",
)))
(warmups)
br {}
(Markdown(concat!(
"## Advanced",
"\n\n",
"The highest level of the ORMC, and the group I spend most of my time with. ",
"Students in ORMC Advanced are in high school, which means ",
"they're ~14-18 years old.",
)))
(advanced)
br {}
(footer())
}
}
})
}

View File

@@ -1,5 +1,9 @@
use maud::html;
use page::page::{Page, PageMetadata};
use maud::{Markup, html};
use page::{
RenderContext,
servable::{Page, PageMetadata},
};
use std::pin::Pin;
use crate::{
components::{
@@ -8,76 +12,78 @@ use crate::{
md::Markdown,
misc::FarLink,
},
pages::page_wrapper,
pages::{MAIN_TEMPLATE, footer},
};
pub fn index() -> Page {
Page {
meta: PageMetadata {
MAIN_TEMPLATE.derive(
PageMetadata {
title: "Betalupi: About".into(),
author: Some("Mark".into()),
description: None,
image: Some("/assets/img/icon.png".to_owned()),
backlinks: Some(false),
},
render,
)
}
generate_html: Box::new(move |page, _ctx| {
Box::pin(async {
let inner = html! {
h2 id="about" { "About" }
fn render<'a>(
_page: &'a Page,
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
Box::pin(async {
html! {
div class="wrapper" style="margin-top:3ex;" {
h2 id="about" { "About" }
div {
div {
img
class="img-placeholder"
src="/assets/img/cover-small.jpg?t=maxdim(20,20)"
data-large="/assets/img/cover-small.jpg"
style="image-rendering:pixelated;float:left;margin:10px;display:block;width:25%;"
{}
img
class="img-placeholder"
src="/assets/img/cover-small.jpg?t=maxdim(20,20)"
data-large="/assets/img/cover-small.jpg"
style="image-rendering:pixelated;float:left;margin:10px;display:block;width:25%;"
{}
div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" {
"Welcome, you've reached Mark's main page. Here you'll find"
" links to various projects I've worked on."
div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" {
"Welcome, you've reached Mark's main page. Here you'll find"
" links to various projects I've worked on."
ul {
li { (MangledBetaEmail {}) }
li { (MangledGoogleEmail {}) }
ul {
li { (MangledBetaEmail {}) }
li { (MangledGoogleEmail {}) }
li {
(
FarLink(
"https://github.com/rm-dr",
html!(
(FAIcon::Github)
"rm-dr"
)
li {
(
FarLink(
"https://github.com/rm-dr",
html!(
(FAIcon::Github)
"rm-dr"
)
)
}
)
}
li {
(
FarLink(
"https://git.betalupi.com",
html!(
(FAIcon::Git)
"git.betalupi.com"
)
li {
(
FarLink(
"https://git.betalupi.com",
html!(
(FAIcon::Git)
"git.betalupi.com"
)
)
}
)
}
}
br style="clear:both;" {}
}
br style="clear:both;" {}
}
(Markdown(include_str!("index.md")))
};
(Markdown(include_str!("index.md")))
page_wrapper(&page.meta, inner, true).await
})
}),
..Default::default()
}
(footer())
}
}
})
}

View File

@@ -1,12 +1,13 @@
use chrono::TimeDelta;
use maud::{DOCTYPE, Markup, PreEscaped, html};
use page::page::{Page, PageMetadata};
use reqwest::StatusCode;
use std::pin::Pin;
use maud::{Markup, PreEscaped, html};
use page::{
RenderContext,
servable::{Page, PageMetadata, PageTemplate},
};
use crate::components::{
fa::FAIcon,
md::{Markdown, backlinks, meta_from_markdown},
md::{Markdown, meta_from_markdown},
misc::FarLink,
};
@@ -67,120 +68,119 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
let html = PreEscaped(md.render());
Page {
meta,
immutable: true,
response_code: StatusCode::OK,
html_ttl: Some(TimeDelta::days(1)),
generate_html: Box::new(move |page, ctx| {
MAIN_TEMPLATE
.derive(meta, move |_page, ctx| {
let html = html.clone();
Box::pin(async move {
let inner = html! {
@if let Some(backlinks) = backlinks(page, ctx) {
(backlinks)
html! {
div class="wrapper" style="margin-top:3ex;" {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks)
}
(html)
(footer())
}
(html)
};
page_wrapper(&page.meta, inner, true).await
}
})
}),
}
})
.html_ttl(Some(TimeDelta::days(1)))
.immutable(true)
}
//
// MARK: wrapper
// MARK: components
//
pub fn page_wrapper<'a>(
meta: &'a PageMetadata,
inner: Markup,
footer: bool,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move {
const MAIN_TEMPLATE: PageTemplate = PageTemplate {
// Order matters, base htmx goes first
scripts_linked: &["/assets/htmx.js", "/assets/htmx-json.js"],
// TODO: use htmx for this
scripts_inline: &["
window.onload = function() {
var imgs = document.querySelectorAll('.img-placeholder');
imgs.forEach(img => {
img.style.border = 'none';
img.style.filter = 'blur(10px)';
img.style.transition = 'filter 0.3s';
var lg = new Image();
lg.src = img.dataset.large;
lg.onload = function () {
img.src = img.dataset.large;
img.style.filter = 'blur(0px)';
};
})
}
"],
styles_inline: &[],
styles_linked: &["/assets/css/main.css"],
extra_meta: &[(
"viewport",
"width=device-width,initial-scale=1,user-scalable=no",
)],
..PageTemplate::const_default()
};
pub fn backlinks(ctx: &RenderContext) -> Option<Markup> {
let mut backlinks = vec![("/", "home")];
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
let last = segments.pop();
let mut end = 0;
for s in segments {
end += s.len();
backlinks.push((&ctx.route[0..=end], s));
end += 1; // trailing slash
}
last.map(|last| {
html! {
(DOCTYPE)
html {
head {
meta charset="UTF8" {}
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=("/assets/css/main.css") {}
(&meta)
title { (PreEscaped(meta.title.clone())) }
// Use a small blurred placeholder while full-size images load.
// Requires no other special scripts or css, just add some tags
// to your <img>!
script {
(PreEscaped("
window.onload = function() {
var imgs = document.querySelectorAll('.img-placeholder');
imgs.forEach(img => {
img.style.border = 'none';
img.style.filter = 'blur(10px)';
img.style.transition = 'filter 0.3s';
var lg = new Image();
lg.src = img.dataset.large;
lg.onload = function () {
img.src = img.dataset.large;
img.style.filter = 'blur(0px)';
};
})
}
"))
}
div {
@for (url, text) in backlinks {
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
"/"
}
body {
main{
div class="wrapper" style=(
// for 404 page. Margin makes it scroll.
match footer {
true => "margin-top:3ex;",
false =>""
}
) {
(inner)
@if footer {
footer style="margin-top:10rem;" {
hr class = "footline" {}
div class = "footContainer" {
p {
"This site was built by hand with "
(FarLink("https://rust-lang.org", "Rust"))
", "
(FarLink("https://maud.lambda.xyz", "Maud"))
", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum"))
". "
(
FarLink(
"https://git.betalupi.com/Mark/webpage",
html!(
(FAIcon::Git)
"Source here!"
)
)
)
}
}
}
}
}
}}
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
}
}
})
}
pub fn footer() -> Markup {
html!(
footer style="margin-top:10rem;" {
hr class = "footline";
div class = "footContainer" {
p {
"This site was built by hand with "
(FarLink("https://rust-lang.org", "Rust"))
", "
(FarLink("https://maud.lambda.xyz", "Maud"))
", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum"))
". "
(
FarLink(
"https://git.betalupi.com/Mark/webpage",
html!(
(FAIcon::Git)
"Source here!"
)
)
)
}
}
}
)
}

View File

@@ -1,34 +1,29 @@
use maud::html;
use page::page::{Page, PageMetadata};
use page::servable::{Page, PageMetadata};
use reqwest::StatusCode;
use crate::pages::page_wrapper;
use crate::pages::MAIN_TEMPLATE;
pub fn notfound() -> Page {
Page {
meta: PageMetadata {
title: "Betalupi: About".into(),
author: None,
MAIN_TEMPLATE.derive(
PageMetadata {
title: "Page not found".into(),
author:None,
description: None,
image: Some("/assets/img/icon.png".to_owned()),
backlinks: Some(false),
},
response_code: StatusCode::NOT_FOUND,
generate_html: Box::new(move |page, _ctx| {
move |_page, _ctx| {
Box::pin(async {
let inner = html! {
div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh" {
p style="font-weight:bold;font-size:50pt;margin:0;" { "404" }
p style="font-size:13pt;margin:0;color:var(--grey);" { "(page not found)" }
a style="font-size:12pt;margin:10pt;padding:5px;" href="/" {"<- Back to site"}
html! {
div class="wrapper" {
div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh" {
p style="font-weight:bold;font-size:50pt;margin:0;" { "404" }
p style="font-size:13pt;margin:0;color:var(--grey);" { "(page not found)" }
a style="font-size:12pt;margin:10pt;padding:5px;" href="/" {"<- Back to site"}
}
}
};
page_wrapper(&page.meta, inner, false).await
}
})
}),
..Default::default()
}
},
).response_code(StatusCode::NOT_FOUND)
}

View File

@@ -1,27 +1,39 @@
use axum::Router;
use macro_sass::sass;
use page::{PageServer, asset::StaticAsset, redirect::Redirect};
use std::sync::Arc;
use page::{
ServableRoute,
servable::{Redirect, StaticAsset},
};
use toolbox::mime::MimeType;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use crate::pages;
pub(super) fn router() -> Router<()> {
build_server().into_router()
let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
build_server().into_router().layer(compression)
}
fn build_server() -> Arc<PageServer> {
let server = PageServer::new();
#[expect(clippy::unwrap_used)]
server
fn build_server() -> ServableRoute {
ServableRoute::new()
.with_404(pages::notfound())
.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", {
#[expect(clippy::unwrap_used)]
Redirect::new("/handouts").unwrap()
})
.add_page("/htwah/typesetting", pages::htwah_typesetting())
.add_page("/assets/htmx.js", page::HTMX_2_0_8)
.add_page("/assets/htmx-json.js", page::EXT_JSON_1_19_12)
//
.add_page(
"/assets/css/main.css",
@@ -185,9 +197,7 @@ fn build_server() -> Arc<PageServer> {
bytes: include_bytes!("../../assets/htwah/spacing-b.pdf"),
mime: MimeType::Pdf,
},
);
server
)
}
#[test]
@@ -197,7 +207,6 @@ fn server_builds_without_panic() {
.build()
.unwrap()
.block_on(async {
// Needs tokio context
let _server = build_server().into_router();
let _server = build_server();
});
}