Compare commits
2 Commits
e9863707d8
...
2ee3ad3898
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ee3ad3898 | |||
| 063ea165d1 |
126
Cargo.lock
generated
126
Cargo.lock
generated
@@ -29,6 +29,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -263,6 +278,27 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
@@ -282,6 +318,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -368,9 +406,12 @@ version = "0.4.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -618,6 +659,12 @@ 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"
|
||||
@@ -785,6 +832,11 @@ 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"
|
||||
@@ -1096,6 +1148,16 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.82"
|
||||
@@ -1200,6 +1262,15 @@ dependencies = [
|
||||
"prost-types",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -1465,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"
|
||||
@@ -1566,6 +1652,12 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
@@ -2141,12 +2233,14 @@ dependencies = [
|
||||
"macro-sass",
|
||||
"markdown-it",
|
||||
"maud",
|
||||
"page",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_yaml",
|
||||
"strum",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -2532,13 +2626,17 @@ version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -3342,3 +3440,31 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -77,7 +78,7 @@ service-webpage = { path = "crates/service/service-webpage" }
|
||||
# MARK: Server
|
||||
#
|
||||
axum = { version = "0.8.6", features = ["macros", "multipart"] }
|
||||
tower-http = { version = "0.6.6", features = ["trace"] }
|
||||
tower-http = { version = "0.6.6", features = ["trace", "compression-full"] }
|
||||
utoipa = "5.4.0"
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = [
|
||||
"axum",
|
||||
|
||||
20
crates/lib/page/Cargo.toml
Normal file
20
crates/lib/page/Cargo.toml
Normal 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 }
|
||||
8
crates/lib/page/src/lib.rs
Normal file
8
crates/lib/page/src/lib.rs
Normal 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
105
crates/lib/page/src/page.rs
Normal 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
|
||||
}
|
||||
}
|
||||
60
crates/lib/page/src/requestcontext.rs
Normal file
60
crates/lib/page/src/requestcontext.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
206
crates/lib/page/src/server.rs
Normal file
206
crates/lib/page/src/server.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use libservice::ServiceConnectInfo;
|
||||
use lru::LruCache;
|
||||
use maud::Markup;
|
||||
use parking_lot::Mutex;
|
||||
use std::{collections::HashMap, num::NonZero, pin::Pin, sync::Arc, time::Instant};
|
||||
use tower_http::compression::{CompressionLayer, DefaultPredicate};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use crate::{ClientInfo, RequestContext, page::Page};
|
||||
|
||||
pub struct PageServer {
|
||||
/// If true, expired pages will be rerendered before being sent to the user.
|
||||
/// If false, requests never trigger rerenders. We rely on the rerender task.
|
||||
///
|
||||
/// If true, we deliver fresher pages but delay responses.
|
||||
/// TODO: replace this with a smarter rendering strategy?
|
||||
never_rerender_on_request: bool,
|
||||
|
||||
/// Map of `{ route: page }`
|
||||
pages: Arc<Mutex<HashMap<String, Arc<Page>>>>,
|
||||
|
||||
/// Map of `{ route: (page data, expire time) }`
|
||||
///
|
||||
/// We use an LruCache for bounded memory usage.
|
||||
html_cache: Mutex<LruCache<(String, RequestContext), (String, DateTime<Utc>)>>,
|
||||
|
||||
/// Called whenever we need to render a page.
|
||||
/// - this method should call `page.generate_html()`,
|
||||
/// - wrap the result in `<html><body>`,
|
||||
/// - and add `<head>`
|
||||
/// ```
|
||||
render_page: Box<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ for<'a> Fn(
|
||||
&'a Page,
|
||||
&'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl PageServer {
|
||||
pub fn new(
|
||||
render_page: Box<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ for<'a> Fn(
|
||||
&'a Page,
|
||||
&'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
>,
|
||||
) -> Arc<Self> {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let cache_size = NonZero::new(128).unwrap();
|
||||
|
||||
Arc::new(Self {
|
||||
pages: Arc::new(Mutex::new(HashMap::new())),
|
||||
html_cache: Mutex::new(LruCache::new(cache_size)),
|
||||
render_page,
|
||||
never_rerender_on_request: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_page(&self, route: impl Into<String>, page: Page) -> &Self {
|
||||
#[expect(clippy::expect_used)]
|
||||
let route = route
|
||||
.into()
|
||||
.strip_prefix("/")
|
||||
.expect("page route must start with /")
|
||||
.to_owned();
|
||||
|
||||
self.pages.lock().insert(route, Arc::new(page));
|
||||
self
|
||||
}
|
||||
|
||||
/// Re-render the page at `route`, regardless of cache state.
|
||||
/// Does nothing if there is no page at `route`.
|
||||
///
|
||||
/// Returns the rendered page's content.
|
||||
async fn render_page(
|
||||
&self,
|
||||
reason: &'static str,
|
||||
route: &str,
|
||||
req_ctx: RequestContext,
|
||||
) -> Option<(String, Option<DateTime<Utc>>)> {
|
||||
let now = Utc::now();
|
||||
let start = Instant::now();
|
||||
trace!(message = "Rendering page", route, reason);
|
||||
|
||||
let page = match self.pages.lock().get(route) {
|
||||
Some(x) => x.clone(),
|
||||
None => {
|
||||
warn!(message = "Not rerendering, no such route", route, reason);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let html = (self.render_page)(&page, &req_ctx).await.0;
|
||||
|
||||
let mut expires = None;
|
||||
if let Some(ttl) = page.html_ttl {
|
||||
expires = Some(now + ttl);
|
||||
self.html_cache
|
||||
.lock()
|
||||
.put((route.to_owned(), req_ctx), (html.clone(), now + ttl));
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
trace!(message = "Rendered page", route, reason, time_ms = elapsed);
|
||||
return Some((html, expires));
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
Path(route): Path<String>,
|
||||
State(state): State<Arc<Self>>,
|
||||
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
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
|
||||
);
|
||||
|
||||
let req_ctx = RequestContext { client_info };
|
||||
|
||||
let cache_key = (route.clone(), req_ctx.clone());
|
||||
let now = Utc::now();
|
||||
let mut html_expires = None;
|
||||
|
||||
// Get from cache, if available
|
||||
if let Some((html, expires)) = state.html_cache.lock().get(&cache_key)
|
||||
&& (*expires > now || state.never_rerender_on_request)
|
||||
{
|
||||
html_expires = Some((html.clone(), Some(*expires)));
|
||||
};
|
||||
|
||||
if html_expires.is_none() {
|
||||
html_expires = match state.render_page("request", &route, req_ctx).await {
|
||||
Some(x) => Some(x.clone()),
|
||||
None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
|
||||
};
|
||||
}
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let (html, expires) = html_expires.unwrap();
|
||||
|
||||
let mut headers = HeaderMap::with_capacity(3);
|
||||
headers.append(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
|
||||
let max_age = match expires {
|
||||
Some(expires) => (expires - now).num_seconds().max(1),
|
||||
None => 1,
|
||||
};
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
headers.append(
|
||||
header::CACHE_CONTROL,
|
||||
// immutable; public/private
|
||||
HeaderValue::from_str(&format!("immutable, public, max-age={}", max_age)).unwrap(),
|
||||
);
|
||||
|
||||
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
|
||||
|
||||
return (headers, html).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, conn, headers| async {
|
||||
Self::handler(Path(String::new()), state, conn, headers).await
|
||||
}),
|
||||
)
|
||||
.route("/{*path}", get(Self::handler))
|
||||
.layer(compression)
|
||||
.with_state(self)
|
||||
}
|
||||
}
|
||||
@@ -142,8 +142,18 @@ pub fn assets(input: TokenStream) -> TokenStream {
|
||||
quote! {
|
||||
#[doc = #router_doc]
|
||||
pub fn #router_name() -> (&'static str, ::axum::Router<()>) {
|
||||
use ::tower_http::compression::{CompressionLayer, DefaultPredicate};
|
||||
|
||||
let compression: CompressionLayer = CompressionLayer::new()
|
||||
.br(true)
|
||||
.deflate(true)
|
||||
.gzip(true)
|
||||
.zstd(true)
|
||||
.compress_when(DefaultPredicate::new());
|
||||
|
||||
let router = ::axum::Router::new()
|
||||
#(#route_definitions)*;
|
||||
#(#route_definitions)*
|
||||
.layer(compression);
|
||||
(#prefix, router)
|
||||
}
|
||||
}
|
||||
@@ -240,27 +250,39 @@ impl Parse for AssetDefinition {
|
||||
match field_name.to_string().as_str() {
|
||||
"source" => {
|
||||
if source.is_some() {
|
||||
return Err(syn::Error::new(field_name.span(), "duplicate 'source' field"));
|
||||
return Err(syn::Error::new(
|
||||
field_name.span(),
|
||||
"duplicate 'source' field",
|
||||
));
|
||||
}
|
||||
source = Some(content.parse()?);
|
||||
}
|
||||
"target" => {
|
||||
if target.is_some() {
|
||||
return Err(syn::Error::new(field_name.span(), "duplicate 'target' field"));
|
||||
return Err(syn::Error::new(
|
||||
field_name.span(),
|
||||
"duplicate 'target' field",
|
||||
));
|
||||
}
|
||||
let target_lit: LitStr = content.parse()?;
|
||||
target = Some(target_lit.value());
|
||||
}
|
||||
"headers" => {
|
||||
if headers.is_some() {
|
||||
return Err(syn::Error::new(field_name.span(), "duplicate 'headers' field"));
|
||||
return Err(syn::Error::new(
|
||||
field_name.span(),
|
||||
"duplicate 'headers' field",
|
||||
));
|
||||
}
|
||||
headers = Some(content.parse()?);
|
||||
}
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
field_name.span(),
|
||||
format!("unknown field '{}', expected 'source', 'target', or 'headers'", field_name)
|
||||
format!(
|
||||
"unknown field '{}', expected 'source', 'target', or 'headers'",
|
||||
field_name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -272,8 +294,10 @@ impl Parse for AssetDefinition {
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
let source = source.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'source'"))?;
|
||||
let target = target.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'target'"))?;
|
||||
let source = source
|
||||
.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'source'"))?;
|
||||
let target = target
|
||||
.ok_or_else(|| syn::Error::new(name.span(), "missing required field 'target'"))?;
|
||||
|
||||
Ok(AssetDefinition {
|
||||
name,
|
||||
|
||||
@@ -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,4 +26,5 @@ lazy_static = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -2,7 +2,6 @@ use axum::Router;
|
||||
use libservice::ToService;
|
||||
|
||||
mod components;
|
||||
mod page;
|
||||
mod pages;
|
||||
mod routes;
|
||||
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
//
|
||||
// MARK: metadata
|
||||
//
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use libservice::ServiceConnectInfo;
|
||||
use markdown_it::Node;
|
||||
use maud::{Markup, PreEscaped, Render, html};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tracing::{trace, warn};
|
||||
|
||||
use crate::components::{
|
||||
md::{FrontMatter, Markdown},
|
||||
misc::Backlinks,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
|
||||
pub struct PageMetadata {
|
||||
pub title: String,
|
||||
pub author: Option<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
|
||||
//
|
||||
|
||||
// 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) -> 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) -> Markup {
|
||||
(self.generate_html)(self).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
|
||||
//
|
||||
|
||||
pub struct PageServer {
|
||||
/// If true, expired pages will be rerendered before being sent to the user.
|
||||
/// If false, requests never trigger rerenders. We rely on the rerender task.
|
||||
///
|
||||
/// If true, we deliver fresher pages but delay responses.
|
||||
/// TODO: replace this with a smarter rendering strategy?
|
||||
never_rerender_on_request: bool,
|
||||
|
||||
/// Map of `{ route: page }`
|
||||
pages: Arc<Mutex<HashMap<String, Arc<Page>>>>,
|
||||
|
||||
/// Map of `{ route: (page data, expire time) }`
|
||||
///
|
||||
/// We use an LruCache for bounded memory usage.
|
||||
html_cache: RwLock<HashMap<String, (String, DateTime<Utc>)>>,
|
||||
|
||||
/// Called whenever we need to render a page.
|
||||
/// - this method should call `page.generate_html()`,
|
||||
/// - wrap the result in `<html><body>`,
|
||||
/// - and add `<head>`
|
||||
/// ```
|
||||
render_page: Box<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl PageServer {
|
||||
pub fn new(
|
||||
render_page: Box<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
pages: Arc::new(Mutex::new(HashMap::new())),
|
||||
html_cache: RwLock::new(HashMap::new()),
|
||||
render_page,
|
||||
never_rerender_on_request: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_page(&self, route: impl Into<String>, page: Page) -> &Self {
|
||||
#[expect(clippy::expect_used)]
|
||||
let route = route
|
||||
.into()
|
||||
.strip_prefix("/")
|
||||
.expect("page route must start with /")
|
||||
.to_owned();
|
||||
|
||||
self.pages.lock().insert(route, Arc::new(page));
|
||||
self
|
||||
}
|
||||
|
||||
/// Re-render the page at `route`, regardless of cache state.
|
||||
/// Does nothing if there is no page at `route`.
|
||||
///
|
||||
/// Returns the rendered page's content.
|
||||
async fn render_page(&self, reason: &'static str, route: &str) -> Option<String> {
|
||||
let now = Utc::now();
|
||||
let start = Instant::now();
|
||||
trace!(message = "Rendering page", route, reason);
|
||||
|
||||
let page = match self.pages.lock().get(route) {
|
||||
Some(x) => x.clone(),
|
||||
None => {
|
||||
warn!(message = "Not rerendering, no such route", route, reason);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let html = (self.render_page)(&page).await.0;
|
||||
|
||||
if let Some(ttl) = page.html_ttl {
|
||||
self.html_cache
|
||||
.write()
|
||||
.insert(route.to_owned(), (html.clone(), now + ttl));
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
trace!(message = "Rendered page", route, reason, time_ms = elapsed);
|
||||
return Some(html);
|
||||
}
|
||||
|
||||
// Rerender considerations:
|
||||
// - rerendering often in the background is wasteful. Maybe we should fall asleep?
|
||||
// - rerendering on request is slow
|
||||
// - rerendering in the background after a request could be a good idea. Maybe implement?
|
||||
//
|
||||
// - cached pages only make sense for static assets.
|
||||
// - user pages can't be pre-rendered!
|
||||
pub async fn start_rerender_task(self: Arc<Self>, interval: Duration) {
|
||||
loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
|
||||
let now = Utc::now();
|
||||
let pages = self
|
||||
.pages
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|(_, v)| v.html_ttl.is_some())
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for route in pages {
|
||||
let needs_render = match self.html_cache.read().get(&route) {
|
||||
Some(x) => x.1 < now, // Expired
|
||||
None => true, // Never rendered
|
||||
};
|
||||
|
||||
if needs_render {
|
||||
self.render_page("rerender_task", &route).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
Path(route): Path<String>,
|
||||
State(state): State<Arc<Self>>,
|
||||
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
trace!(message = "Serving route", route, addr = ?addr.addr, user_agent = ?headers["user-agent"]);
|
||||
|
||||
let now = Utc::now();
|
||||
let headers = [(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
)];
|
||||
|
||||
if let Some((html, expires)) = state.html_cache.read().get(&route)
|
||||
&& (*expires > now || state.never_rerender_on_request)
|
||||
{
|
||||
// TODO: no clone?
|
||||
return (headers, html.clone()).into_response();
|
||||
};
|
||||
|
||||
let html = match state.render_page("request", &route).await {
|
||||
Some(x) => x.clone(),
|
||||
None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
|
||||
};
|
||||
|
||||
return (headers, html).into_response();
|
||||
}
|
||||
|
||||
pub fn into_router(self: Arc<Self>) -> Router<()> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|state, conn, headers| async {
|
||||
Self::handler(Path(String::new()), state, conn, headers).await
|
||||
}),
|
||||
)
|
||||
.route("/{*path}", get(Self::handler))
|
||||
.with_state(self)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
use std::time::Instant;
|
||||
use std::{
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use assetserver::Asset;
|
||||
use chrono::TimeDelta;
|
||||
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::{Page, PageMetadata},
|
||||
routes::assets::Image_Icon,
|
||||
};
|
||||
|
||||
@@ -23,6 +28,66 @@ struct HandoutEntry {
|
||||
solutions: Option<String>,
|
||||
}
|
||||
|
||||
struct CachedRequestInner<T> {
|
||||
last_fetch: DateTime<Utc>,
|
||||
last_value: Option<Arc<T>>,
|
||||
}
|
||||
|
||||
pub struct CachedRequest<T> {
|
||||
inner: Mutex<CachedRequestInner<T>>,
|
||||
ttl: TimeDelta,
|
||||
get: Box<dyn Fn() -> Pin<Box<dyn Future<Output = T> + Send + Sync>> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl<T> CachedRequest<T> {
|
||||
pub fn new(
|
||||
ttl: TimeDelta,
|
||||
get: Box<dyn Fn() -> Pin<Box<dyn Future<Output = T> + Send + Sync>> + Send + Sync>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
get,
|
||||
ttl,
|
||||
inner: Mutex::new(CachedRequestInner {
|
||||
last_fetch: Utc::now(),
|
||||
last_value: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(self: Arc<Self>) -> Arc<T> {
|
||||
let now = Utc::now();
|
||||
let expires = self.inner.lock().last_fetch + self.ttl;
|
||||
|
||||
if now < expires
|
||||
&& let Some(last_value) = self.inner.lock().last_value.clone()
|
||||
{
|
||||
return last_value;
|
||||
}
|
||||
|
||||
let res = Arc::new((self.get)().await);
|
||||
|
||||
let mut inner = self.inner.lock();
|
||||
inner.last_fetch = now;
|
||||
inner.last_value = Some(res.clone());
|
||||
return res;
|
||||
}
|
||||
|
||||
pub async fn autoget(self: Arc<Self>, interval: Duration) {
|
||||
loop {
|
||||
{
|
||||
let now = Utc::now();
|
||||
let res = Arc::new((self.get)().await);
|
||||
|
||||
let mut inner = self.inner.lock();
|
||||
inner.last_fetch = now;
|
||||
inner.last_value = Some(res.clone());
|
||||
}
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
|
||||
let start = Instant::now();
|
||||
let res = reqwest::get(
|
||||
@@ -49,28 +114,59 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
fn build_list_for_group(handouts: &[HandoutEntry], group: &str) -> Markup {
|
||||
html! {
|
||||
ul class="handout-ul" {
|
||||
fn build_list_for_group(
|
||||
handouts: &[HandoutEntry],
|
||||
group: &str,
|
||||
req_ctx: &RequestContext,
|
||||
) -> Markup {
|
||||
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
|
||||
|
||||
@for h in handouts {
|
||||
@if h.group ==group {
|
||||
li {
|
||||
span class="handdout-li-title" {
|
||||
strong { (h.title) }
|
||||
}
|
||||
" "
|
||||
span class="handout-li-links" {
|
||||
"[ "
|
||||
if mobile {
|
||||
html! {
|
||||
ul class="handout-ul" {
|
||||
|
||||
@for h in handouts {
|
||||
@if h.group ==group {
|
||||
li {
|
||||
span class="handout-li-title" {
|
||||
a href=(h.handout) class="underline-link" {
|
||||
strong style="text-decoration: underline;text-underline-offset:1.5pt;color:var(--fgColor);" { (h.title) }
|
||||
}
|
||||
}
|
||||
|
||||
@if let Some(solutions) = &h.solutions {
|
||||
a href=(h.handout) {"handout"}
|
||||
" | "
|
||||
a href=(solutions) {"solutions"}
|
||||
} @else {
|
||||
a href=(h.handout) {"handout"}
|
||||
" ["
|
||||
a href=(solutions) { "sols" }
|
||||
"]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
ul class="handout-ul" {
|
||||
|
||||
@for h in handouts {
|
||||
@if h.group ==group {
|
||||
li {
|
||||
span class="handout-li-title" {
|
||||
strong { (h.title) }
|
||||
}
|
||||
" "
|
||||
span class="handout-li-links" {
|
||||
"[ "
|
||||
|
||||
@if let Some(solutions) = &h.solutions {
|
||||
a href=(h.handout) {"handout"}
|
||||
" | "
|
||||
a href=(solutions) {"solutions"}
|
||||
} @else {
|
||||
a href=(h.handout) {"handout"}
|
||||
}
|
||||
" ]"
|
||||
}
|
||||
" ]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,10 +182,15 @@ fn build_list_for_group(handouts: &[HandoutEntry], group: &str) -> Markup {
|
||||
pub fn handouts() -> Page {
|
||||
let md = Markdown::parse(include_str!("handouts.md"));
|
||||
|
||||
let index = CachedRequest::new(
|
||||
TimeDelta::minutes(30),
|
||||
Box::new(|| Box::pin(async move { get_index().await })),
|
||||
);
|
||||
|
||||
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());
|
||||
@@ -101,44 +202,36 @@ pub fn handouts() -> Page {
|
||||
meta,
|
||||
html_ttl: Some(TimeDelta::seconds(300)),
|
||||
|
||||
generate_html: Box::new(move |page| {
|
||||
generate_html: Box::new(move |page, req_ctx| {
|
||||
let html = html.clone(); // TODO: find a way to not clone here
|
||||
let index = index.clone();
|
||||
Box::pin(async move {
|
||||
let handouts = get_index().await;
|
||||
let handouts = index.get().await;
|
||||
|
||||
let warmups = match &handouts {
|
||||
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups"),
|
||||
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", req_ctx),
|
||||
Err(error) => {
|
||||
warn!("Could not load handout index: {error:?}");
|
||||
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."
|
||||
)
|
||||
)
|
||||
}
|
||||
fallback.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let advanced = match &handouts {
|
||||
Ok(handouts) => build_list_for_group(handouts, "Advanced"),
|
||||
Err(_) => 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 advanced = match &*handouts {
|
||||
Ok(handouts) => build_list_for_group(handouts, "Advanced", req_ctx),
|
||||
Err(_) => fallback,
|
||||
};
|
||||
|
||||
html! {
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn index() -> Page {
|
||||
slug: None,
|
||||
},
|
||||
|
||||
generate_html: Box::new(move |_page| {
|
||||
generate_html: Box::new(move |_page, _| {
|
||||
Box::pin(async {
|
||||
html! {
|
||||
h2 id="about" { "About" }
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
use std::{pin::Pin, sync::Arc, time::Duration};
|
||||
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},
|
||||
pages,
|
||||
routes::assets::Styles_Main,
|
||||
};
|
||||
use crate::{components::misc::FarLink, pages, routes::assets::Styles_Main};
|
||||
|
||||
pub mod assets;
|
||||
|
||||
@@ -18,9 +14,7 @@ pub(super) fn router() -> Router<()> {
|
||||
let (asset_prefix, asset_router) = assets::asset_router();
|
||||
info!("Serving assets at {asset_prefix}");
|
||||
|
||||
let server = build_server();
|
||||
tokio::task::spawn(server.clone().start_rerender_task(Duration::from_secs(3)));
|
||||
let router = server.into_router();
|
||||
let router = build_server().into_router();
|
||||
|
||||
Router::new().merge(router).nest(asset_prefix, asset_router)
|
||||
}
|
||||
@@ -35,7 +29,10 @@ fn build_server() -> Arc<PageServer> {
|
||||
server
|
||||
}
|
||||
|
||||
fn page_wrapper<'a>(page: &'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
|
||||
fn page_wrapper<'a>(
|
||||
page: &'a Page,
|
||||
req_ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
|
||||
Box::pin(async move {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
@@ -54,7 +51,7 @@ fn page_wrapper<'a>(page: &'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a
|
||||
|
||||
body {
|
||||
div class="wrapper" {
|
||||
main { ( page.generate_html().await ) }
|
||||
main { ( page.generate_html(req_ctx).await ) }
|
||||
|
||||
footer {
|
||||
hr class = "footline" {}
|
||||
|
||||
Reference in New Issue
Block a user