Render handout page on server
All checks were successful
CI / Check typos (push) Successful in 22s
CI / Check links (push) Successful in 23s
CI / Clippy (push) Successful in 1m5s
CI / Build and test (push) Successful in 1m12s
CI / Build container (push) Successful in 1m37s
CI / Deploy on waypoint (push) Successful in 45s

This commit is contained in:
2025-11-04 19:28:21 -08:00
parent 62a3da195f
commit 7afc0b2a29
11 changed files with 609 additions and 365 deletions

198
Cargo.lock generated
View File

@@ -128,6 +128,19 @@ checksum = "3f8ebf5827e4ac4fd5946560e6a99776ea73b596d80898f357007317a7141e47"
name = "assetserver" name = "assetserver"
version = "0.0.1" version = "0.0.1"
[[package]]
name = "async-compression"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89bce6054c720275ac2432fbba080a66a2106a44a1b804553930ca6909f4e0"
dependencies = [
"compression-codecs",
"compression-core",
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -349,6 +362,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "compression-codecs"
version = "0.4.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23"
dependencies = [
"compression-core",
"flate2",
"memchr",
]
[[package]]
name = "compression-core"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb"
[[package]] [[package]]
name = "const_format" name = "const_format"
version = "0.2.35" version = "0.2.35"
@@ -375,6 +405,35 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie",
"document-features",
"idna 1.1.0",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -474,6 +533,15 @@ dependencies = [
"syn 2.0.108", "syn 2.0.108",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "downcast-rs" name = "downcast-rs"
version = "1.2.1" version = "1.2.1"
@@ -550,12 +618,6 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -572,6 +634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -580,6 +643,29 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.31" version = "0.3.31"
@@ -593,9 +679,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab",
] ]
[[package]] [[package]]
@@ -660,6 +751,25 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
] ]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -675,11 +785,6 @@ name = "hashbrown"
version = "0.16.0" version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "heck" name = "heck"
@@ -752,6 +857,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2",
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
@@ -1063,6 +1169,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1088,15 +1200,6 @@ dependencies = [
"prost-types", "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]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -1553,6 +1656,12 @@ dependencies = [
"prost", "prost",
] ]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]] [[package]]
name = "psm" name = "psm"
version = "0.1.28" version = "0.1.28"
@@ -1563,6 +1672,16 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna 1.1.0",
"psl-types",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.3" version = "0.38.3"
@@ -1756,9 +1875,16 @@ version = "0.12.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
dependencies = [ dependencies = [
"async-compression",
"base64", "base64",
"bytes", "bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"h2",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -1767,6 +1893,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
@@ -1778,12 +1905,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
] ]
@@ -2008,15 +2137,16 @@ dependencies = [
"emojis", "emojis",
"lazy_static", "lazy_static",
"libservice", "libservice",
"lru",
"macro-assets", "macro-assets",
"macro-sass", "macro-sass",
"markdown-it", "markdown-it",
"maud", "maud",
"parking_lot", "parking_lot",
"reqwest",
"serde", "serde",
"serde_yaml", "serde_yaml",
"strum", "strum",
"tokio",
"tracing", "tracing",
] ]
@@ -2350,6 +2480,19 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-util"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "toolbox" name = "toolbox"
version = "0.0.1" version = "0.0.1"
@@ -2755,6 +2898,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.82" version = "0.3.82"

View File

@@ -74,7 +74,7 @@ service-webpage = { path = "crates/service/service-webpage" }
# #
# MARK: Servers # MARK: Server
# #
axum = { version = "0.8.6", features = ["macros", "multipart"] } axum = { version = "0.8.6", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace"] } tower-http = { version = "0.6.6", features = ["trace"] }
@@ -88,6 +88,17 @@ maud = { version = "0.27.0", features = ["axum"] }
grass = "0.13.4" grass = "0.13.4"
markdown-it = "0.6.1" markdown-it = "0.6.1"
emojis = "0.8.0" emojis = "0.8.0"
reqwest = { version = "0.12.24", default-features = false, features = [
"http2",
"rustls-tls",
"cookies",
"gzip",
"stream",
"json",
"charset",
"blocking",
] }
# #
# MARK: Async & Parallelism # MARK: Async & Parallelism

View File

@@ -68,6 +68,8 @@ impl From<LoggingConfig> for EnvFilter {
format!("h2={}", conf.silence), format!("h2={}", conf.silence),
format!("rustls={}", conf.silence), format!("rustls={}", conf.silence),
format!("tower={}", conf.silence), format!("tower={}", conf.silence),
format!("reqwest={}", conf.silence),
format!("axum={}", conf.silence),
// //
// Libs // Libs
// //

View File

@@ -21,7 +21,8 @@ emojis = { workspace = true }
strum = { workspace = true } strum = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
lru = { workspace = true }
lazy_static = { workspace = true } lazy_static = { workspace = true }
serde_yaml = { workspace = true } serde_yaml = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
reqwest = { workspace = true }
tokio = { workspace = true }

View File

@@ -1,6 +1,3 @@
// Handout list pages
// works with "{{ handout() }}" shortcode.
.handout-li-links { .handout-li-links {
color: var(--grey); color: var(--grey);
} }
@@ -39,10 +36,6 @@
display: none; display: none;
} }
.handout-star {
color: var(--yellow);
}
// Email obfuscation // Email obfuscation
// Works with "{{ email_*() }}" shortcodes. // Works with "{{ email_*() }}" shortcodes.
.eobf { .eobf {

View File

@@ -9,14 +9,19 @@ use axum::{
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use libservice::ServiceConnectInfo; use libservice::ServiceConnectInfo;
use lru::LruCache; use markdown_it::Node;
use maud::{Markup, PreEscaped, Render, html}; use maud::{Markup, PreEscaped, Render, html};
use parking_lot::Mutex; use parking_lot::{Mutex, RwLock};
use serde::Deserialize; use serde::Deserialize;
use std::{collections::HashMap, num::NonZero, sync::Arc, time::Duration}; use std::{
use tracing::{debug, trace}; collections::HashMap,
pin::Pin,
sync::Arc,
time::{Duration, Instant},
};
use tracing::{trace, warn};
use crate::components::{ use crate::components::{
md::{FrontMatter, Markdown}, md::{FrontMatter, Markdown},
@@ -70,6 +75,21 @@ impl Render for PageMetadata {
} }
} }
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 // MARK: page
// //
@@ -79,9 +99,10 @@ pub struct Page {
pub meta: PageMetadata, pub meta: PageMetadata,
/// How long this page's html may be cached. /// 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. /// If `None`, this page is always rendered from scratch.
pub html_ttl: Option<Duration>, pub html_ttl: Option<TimeDelta>,
/// A function that generates this page's html. /// A function that generates this page's html.
/// ///
@@ -89,41 +110,40 @@ pub struct Page {
/// or the contents of a wrapper element (defined in the page server struct). /// or the contents of a wrapper element (defined in the page server struct).
/// ///
/// This closure must never return `<html>` or `<head>`. /// This closure must never return `<html>` or `<head>`.
pub generate_html: Box<dyn Send + Sync + Fn(&Self) -> Markup>, 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 { impl Default for Page {
fn default() -> Self { fn default() -> Self {
Page { Page {
meta: Default::default(), meta: Default::default(),
html_ttl: Some(Duration::from_secs(60 * 24 * 30)), html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)),
//css_ttl: Duration::from_secs(60 * 24 * 30), //css_ttl: Duration::from_secs(60 * 24 * 30),
//generate_css: None, //generate_css: None,
generate_html: Box::new(|_| html!()), generate_html: Box::new(|_| Box::pin(async { html!() })),
} }
} }
} }
impl Page { impl Page {
pub fn generate_html(&self) -> Markup { pub async fn generate_html(&self) -> Markup {
(self.generate_html)(self) (self.generate_html)(self).await
} }
pub fn from_markdown(md: impl Into<String>, default_image: Option<String>) -> Self { pub fn from_markdown(md: impl Into<String>, default_image: Option<String>) -> Self {
let md: String = md.into(); let md: String = md.into();
let md = Markdown::parse(&md); let md = Markdown::parse(&md);
let mut meta = md let mut meta = PageMetadata::from_markdown_frontmatter(&md)
.children .unwrap_or(Some(PageMetadata {
.get(0)
.map(|x| x.cast::<FrontMatter>())
.flatten()
.map(|x| serde_yaml::from_str::<PageMetadata>(&x.content))
.unwrap_or(Ok(Default::default()))
.unwrap_or(PageMetadata {
title: "Invalid frontmatter!".into(), title: "Invalid frontmatter!".into(),
..Default::default() ..Default::default()
}); }))
.unwrap_or_default();
if meta.image.is_none() { if meta.image.is_none() {
meta.image = default_image meta.image = default_image
@@ -134,6 +154,8 @@ impl Page {
Page { Page {
meta, meta,
generate_html: Box::new(move |page| { generate_html: Box::new(move |page| {
let html = html.clone();
Box::pin(async move {
html! { html! {
@if let Some(slug) = &page.meta.slug { @if let Some(slug) = &page.meta.slug {
(Backlinks(&[("/", "home")], slug)) (Backlinks(&[("/", "home")], slug))
@@ -141,6 +163,7 @@ impl Page {
(html) (html)
} }
})
}), }),
..Default::default() ..Default::default()
@@ -153,35 +176,50 @@ impl Page {
// //
pub struct PageServer { 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 }` /// Map of `{ route: page }`
pages: HashMap<String, Page>, pages: Arc<Mutex<HashMap<String, Arc<Page>>>>,
/// Map of `{ route: (page data, expire time) }` /// Map of `{ route: (page data, expire time) }`
/// ///
/// We use an LruCache for bounded memory usage. /// We use an LruCache for bounded memory usage.
html_cache: Mutex<LruCache<String, (String, DateTime<Utc>)>>, html_cache: RwLock<HashMap<String, (String, DateTime<Utc>)>>,
/// Called whenever we need to render a page. /// Called whenever we need to render a page.
/// - this method should call `page.generate_html()`, /// - this method should call `page.generate_html()`,
/// - wrap the result in `<html><body>`, /// - wrap the result in `<html><body>`,
/// - and add `<head>` /// - and add `<head>`
/// ``` /// ```
render_page: Box<dyn Send + Sync + Fn(&Page) -> Markup>, render_page: Box<
dyn Send
+ Sync
+ for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
>,
} }
impl PageServer { impl PageServer {
pub fn new(page_wrapper: Box<dyn Send + Sync + Fn(&Page) -> Markup>) -> Self { pub fn new(
#[expect(clippy::unwrap_used)] render_page: Box<
let cache_size = LruCache::new(NonZero::new(128).unwrap()); dyn Send
+ Sync
Self { + for<'a> Fn(&'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
pages: HashMap::new(), >,
html_cache: Mutex::new(cache_size), ) -> Arc<Self> {
render_page: Box::new(page_wrapper), 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(mut self, route: impl Into<String>, page: Page) -> Self { pub fn add_page(&self, route: impl Into<String>, page: Page) -> &Self {
#[expect(clippy::expect_used)] #[expect(clippy::expect_used)]
let route = route let route = route
.into() .into()
@@ -189,24 +227,79 @@ impl PageServer {
.expect("page route must start with /") .expect("page route must start with /")
.to_owned(); .to_owned();
self.pages.insert(route, page); self.pages.lock().insert(route, Arc::new(page));
self 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( async fn handler(
Path(path): Path<String>, Path(route): Path<String>,
State(state): State<Arc<Self>>, State(state): State<Arc<Self>>,
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>, ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
) -> Response { ) -> Response {
trace!("Serving {path} to {}", addr.addr); trace!("Serving {route} to {}", addr.addr);
let page = match state.pages.get(&path) {
Some(x) => x,
// TODO: 404 page
None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
};
let now = Utc::now(); let now = Utc::now();
let headers = [( let headers = [(
@@ -214,30 +307,28 @@ impl PageServer {
HeaderValue::from_static("text/html; charset=utf-8"), HeaderValue::from_static("text/html; charset=utf-8"),
)]; )];
if let Some((html, expires)) = state.html_cache.lock().get(&path) if let Some((html, expires)) = state.html_cache.read().get(&route)
&& *expires > now && (*expires > now || state.never_rerender_on_request)
{ {
// TODO: no clone? // TODO: no clone?
return (headers, html.clone()).into_response(); return (headers, html.clone()).into_response();
}; };
debug!("Rendering {path}"); let html = match state.render_page("request", &route).await {
let html = (state.render_page)(page).0; Some(x) => x.clone(),
None => return (StatusCode::NOT_FOUND, "page doesn't exist").into_response(),
};
if let Some(ttl) = page.html_ttl { return (headers, html).into_response();
state.html_cache.lock().put(path, (html.clone(), now + ttl));
} }
return (headers, html.clone()).into_response(); pub fn into_router(self: Arc<Self>) -> Router<()> {
}
pub fn into_router(self) -> Router<()> {
Router::new() Router::new()
.route( .route(
"/", "/",
get(|state, conn| async { Self::handler(Path(String::new()), state, conn).await }), get(|state, conn| async { Self::handler(Path(String::new()), state, conn).await }),
) )
.route("/{*path}", get(Self::handler)) .route("/{*path}", get(Self::handler))
.with_state(Arc::new(self)) .with_state(self)
} }
} }

View File

@@ -33,200 +33,10 @@ are written with this in mind.\
I do not expect the average student to finish all problems during this two-hour session. I do not expect the average student to finish all problems during this two-hour session.
If the class finishes early, the lesson is either too short or too easy. If the class finishes early, the lesson is either too short or too easy.
The sources for all these handouts are available [here](https://git.betalupi.com/mark/handouts).\
Some are written in LaTeX, some are in [Typst](https://typst.app). \
The latter is vastly superior.
<br></br> <br></br>
<hr></hr> <hr></hr>
<br></br> <br></br>
## Warm-Ups
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.
<ul id="handout-ul-Warm-Ups" class="handout-ul"></ul>
<script>
fetch("https://git.betalupi.com/api/packages/Mark/generic/ormc-handouts/latest/index.json")
.then(res => res.json())
.then(out => {
out = out.sort((a, b) => (
a["title"].toLowerCase() < b["title"].toLowerCase()
));
out.forEach(element => {
if (element["group"] != "Warm-Ups") { return }
// Handout title
const title = document.createElement("span");
const title_a = document.createElement("strong");
title_a.appendChild(document.createTextNode(element["title"] + " "));
title.appendChild(title_a)
title.classList.add("handout-li-title");
// Handout title
const desc = document.createElement("span");
desc.appendChild(document.createTextNode(element["description"]));
desc.classList.add("handout-li-desc");
const handout_link = element["handout"];
const solutions_link = element["solutions"];
const links = document.createElement("span");
links.classList.add("handout-li-links");
const h = document.createElement("a");
h.appendChild(document.createTextNode("handout"))
h.href = handout_link;
if (solutions_link === null) {
links.appendChild(document.createTextNode("[ "));
links.appendChild(h);
links.appendChild(document.createTextNode(" ]"));
} else {
var s = document.createElement("a");
s.appendChild(document.createTextNode("solutions"))
s.href = solutions_link;
links.appendChild(document.createTextNode("[ "));
links.appendChild(h);
links.appendChild(document.createTextNode(" | "));
links.appendChild(s);
links.appendChild(document.createTextNode(" ]"));
}
// Add to main list
const item = document.createElement("li");
item.appendChild(title)
item.appendChild(links);
//item.appendChild(desc)
const list = document.getElementById("handout-ul-Warm-Ups");
list.insertBefore(item, list.children[0]);
})}
)
.catch(err => {
// Print fallback link if we failed to load json index
console.log(err)
const title = document.createElement("span");
const title_a = document.createElement("strong");
title_a.appendChild(document.createTextNode("Error: "));
title.appendChild(title_a)
title.appendChild(document.createTextNode("failed to load handouts, something broke."))
title.classList.add("handout-li-title");
const fallback = "https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest";
const link = document.createElement("a");
link.href = fallback
link.appendChild(document.createTextNode("ormc-handouts"));
const item_a = document.createElement("li");
item_a.appendChild(title)
const item_b = document.createElement("li");
item_b.appendChild(document.createTextNode("Fallback link: "))
item_b.appendChild(link)
const list = document.getElementById("handout-ul-Warm-Ups");
list.insertBefore(item_b, list.children[0]);
list.insertBefore(item_a, list.children[0]);
});
</script>
<br></br>
## Advanced
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.
<ul id="handout-ul-Advanced" class="handout-ul"></ul>
<script>
fetch("https://git.betalupi.com/api/packages/Mark/generic/ormc-handouts/latest/index.json")
.then(res => res.json())
.then(out => {
out = out.sort((a, b) => (
a["title"].toLowerCase() < b["title"].toLowerCase()
));
out.forEach(element => {
if (element["group"] != "Advanced") { return }
// Handout title
const title = document.createElement("span");
const title_a = document.createElement("strong");
title_a.appendChild(document.createTextNode(element["title"] + " "));
title.appendChild(title_a)
title.classList.add("handout-li-title");
// Handout title
const desc = document.createElement("span");
desc.appendChild(document.createTextNode(element["description"]));
desc.classList.add("handout-li-desc");
const handout_link = element["handout"];
const solutions_link = element["solutions"];
const links = document.createElement("span");
links.classList.add("handout-li-links");
const h = document.createElement("a");
h.appendChild(document.createTextNode("handout"))
h.href = handout_link;
if (solutions_link === null) {
links.appendChild(document.createTextNode("[ "));
links.appendChild(h);
links.appendChild(document.createTextNode(" ]"));
} else {
var s = document.createElement("a");
s.appendChild(document.createTextNode("solutions"))
s.href = solutions_link;
links.appendChild(document.createTextNode("[ "));
links.appendChild(h);
links.appendChild(document.createTextNode(" | "));
links.appendChild(s);
links.appendChild(document.createTextNode(" ]"));
}
// Add to main list
const item = document.createElement("li");
item.appendChild(title)
item.appendChild(links);
//item.appendChild(desc)
const list = document.getElementById("handout-ul-Advanced");
list.insertBefore(item, list.children[0]);
})}
)
.catch(err => {
// Print fallback link if we failed to load json index
console.log(err)
const title = document.createElement("span");
const title_a = document.createElement("strong");
title_a.appendChild(document.createTextNode("Error: "));
title.appendChild(title_a)
title.appendChild(document.createTextNode("failed to load handouts, something broke."))
title.classList.add("handout-li-title");
const fallback = "https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest";
const link = document.createElement("a");
link.href = fallback
link.appendChild(document.createTextNode("ormc-handouts"));
const item_a = document.createElement("li");
item_a.appendChild(title)
const item_b = document.createElement("li");
item_b.appendChild(document.createTextNode("Fallback link: "))
item_b.appendChild(link)
const list = document.getElementById("handout-ul-Advanced");
list.insertBefore(item_b, list.children[0]);
list.insertBefore(item_a, list.children[0]);
});
</script>
<br></br>

View File

@@ -0,0 +1,173 @@
use std::time::Instant;
use assetserver::Asset;
use chrono::TimeDelta;
use maud::{Markup, PreEscaped, html};
use serde::Deserialize;
use tracing::{debug, warn};
use crate::{
components::{
md::Markdown,
misc::{Backlinks, FarLink},
},
page::{Page, PageMetadata},
routes::assets::Image_Icon,
};
#[derive(Debug, Deserialize)]
struct HandoutEntry {
title: String,
group: String,
handout: String,
solutions: Option<String>,
}
async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
let start = Instant::now();
let res = reqwest::get(
"https://git.betalupi.com/api/packages/Mark/generic/ormc-handouts/latest/index.json",
)
.await;
let res = match res {
Ok(x) => x,
Err(err) => {
warn!("Error while getting index: {err:?}");
return Err(err);
}
};
let mut res: Vec<HandoutEntry> = res.json().await?;
res.sort_by_key(|x| x.title.clone());
debug!(
message = "Fetched handout index",
n_handouts = res.len(),
time_ms = start.elapsed().as_millis()
);
return Ok(res);
}
fn build_list_for_group(handouts: &[HandoutEntry], group: &str) -> Markup {
html! {
ul class="handout-ul" {
@for h in handouts {
@if h.group ==group {
li {
span class="handdout-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"}
}
"] "
}
}
}
}
}
}
}
//
// MARK: page
//
pub fn handouts() -> Page {
let md = Markdown::parse(include_str!("handouts.md"));
#[expect(clippy::unwrap_used)]
let mut meta = PageMetadata::from_markdown_frontmatter(&md)
.unwrap()
.unwrap();
if meta.image.is_none() {
meta.image = Some(Image_Icon::URL.to_owned());
}
let html = PreEscaped(md.render());
Page {
meta,
html_ttl: Some(TimeDelta::seconds(300)),
generate_html: Box::new(move |page| {
let html = html.clone(); // TODO: find a way to not clone here
Box::pin(async move {
let handouts = get_index().await;
let warmups = match &handouts {
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups"),
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."
)
)
}
}
};
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."
)
)
},
};
html! {
@if let Some(slug) = &page.meta.slug {
(Backlinks(&[("/", "home")], slug))
}
(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 {}
}
})
}),
}
}

View File

@@ -23,6 +23,7 @@ pub fn index() -> Page {
}, },
generate_html: Box::new(move |_page| { generate_html: Box::new(move |_page| {
Box::pin(async {
html! { html! {
h2 id="about" { "About" } h2 id="about" { "About" }
@@ -70,6 +71,7 @@ pub fn index() -> Page {
(Markdown(include_str!("index.md"))) (Markdown(include_str!("index.md")))
} }
})
}), }),
..Default::default() ..Default::default()
} }

View File

@@ -1,9 +1,13 @@
mod index;
use assetserver::Asset; use assetserver::Asset;
pub use index::index;
use crate::{page::Page, routes::assets::Image_Icon}; use crate::{page::Page, routes::assets::Image_Icon};
mod handouts;
mod index;
pub use handouts::handouts;
pub use index::index;
pub fn links() -> Page { pub fn links() -> Page {
/* /*
Dead links: Dead links:
@@ -12,19 +16,12 @@ pub fn links() -> Page {
http://www.3dprintmath.com/ http://www.3dprintmath.com/
*/ */
Page::from_markdown(include_str!("links.md"), Some(Image_Icon::URL.to_string())) Page::from_markdown(include_str!("links.md"), Some(Image_Icon::URL.to_owned()))
} }
pub fn betalupi() -> Page { pub fn betalupi() -> Page {
Page::from_markdown( Page::from_markdown(
include_str!("betalupi.md"), include_str!("betalupi.md"),
Some(Image_Icon::URL.to_string()), Some(Image_Icon::URL.to_owned()),
)
}
pub fn handouts() -> Page {
Page::from_markdown(
include_str!("handouts.md"),
Some(Image_Icon::URL.to_string()),
) )
} }

View File

@@ -1,3 +1,5 @@
use std::{pin::Pin, sync::Arc, time::Duration};
use assetserver::Asset; use assetserver::Asset;
use axum::Router; use axum::Router;
use maud::{DOCTYPE, Markup, PreEscaped, html}; use maud::{DOCTYPE, Markup, PreEscaped, html};
@@ -16,20 +18,25 @@ pub(super) fn router() -> Router<()> {
let (asset_prefix, asset_router) = assets::asset_router(); let (asset_prefix, asset_router) = assets::asset_router();
info!("Serving assets at {asset_prefix}"); info!("Serving assets at {asset_prefix}");
let server = build_server().into_router(); let server = build_server();
tokio::task::spawn(server.clone().start_rerender_task(Duration::from_secs(3)));
let router = server.into_router();
Router::new().merge(server).nest(asset_prefix, asset_router) Router::new().merge(router).nest(asset_prefix, asset_router)
} }
fn build_server() -> PageServer { fn build_server() -> Arc<PageServer> {
PageServer::new(Box::new(page_wrapper)) let server = PageServer::new(Box::new(page_wrapper));
server
.add_page("/", pages::index()) .add_page("/", pages::index())
.add_page("/links", pages::links()) .add_page("/links", pages::links())
.add_page("/whats-a-betalupi", pages::betalupi()) .add_page("/whats-a-betalupi", pages::betalupi())
.add_page("/handouts", pages::handouts()) .add_page("/handouts", pages::handouts());
server
} }
fn page_wrapper(page: &Page) -> Markup { fn page_wrapper<'a>(page: &'a Page) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move {
html! { html! {
(DOCTYPE) (DOCTYPE)
html { html {
@@ -47,7 +54,7 @@ fn page_wrapper(page: &Page) -> Markup {
body { body {
div class="wrapper" { div class="wrapper" {
main { ( page.generate_html() ) } main { ( page.generate_html().await ) }
footer { footer {
hr class = "footline" {} hr class = "footline" {}
@@ -69,6 +76,7 @@ fn page_wrapper(page: &Page) -> Markup {
} }
} }
} }
})
} }
#[test] #[test]