Render handout page on server
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Successful in 7s
CI / Clippy (push) Failing after 1m7s
CI / Build and test (push) Successful in 1m2s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped

This commit is contained in:
2025-11-04 19:15:35 -08:00
parent 62a3da195f
commit a9b782e704
11 changed files with 565 additions and 363 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,23 @@ 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
.get(0)
.map(|x| x.cast::<FrontMatter>())
.flatten()
.map(|x| serde_yaml::from_str::<PageMetadata>(&x.content))
.map_or(Ok(None), |v| v.map(Some))
}
}
// //
// MARK: page // MARK: page
// //
@@ -79,9 +101,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 +112,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::default());
if meta.image.is_none() { if meta.image.is_none() {
meta.image = default_image meta.image = default_image
@@ -134,13 +156,16 @@ impl Page {
Page { Page {
meta, meta,
generate_html: Box::new(move |page| { generate_html: Box::new(move |page| {
html! { let html = html.clone();
@if let Some(slug) = &page.meta.slug { Box::pin(async move {
(Backlinks(&[("/", "home")], slug)) html! {
} @if let Some(slug) = &page.meta.slug {
(Backlinks(&[("/", "home")], slug))
}
(html) (html)
} }
})
}), }),
..Default::default() ..Default::default()
@@ -153,35 +178,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 +229,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 +309,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) -> Router<()> { pub fn into_router(self: Arc<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

@@ -36,197 +36,3 @@ If the class finishes early, the lesson is either too short or too easy.
<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,133 @@
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},
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"));
let mut meta = PageMetadata::from_markdown_frontmatter(&md)
.unwrap()
.unwrap();
if meta.image.is_none() {
meta.image = Some(Image_Icon::URL.to_string());
}
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.unwrap();
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.",
)))
(build_list_for_group(&handouts, "Warm-Ups"))
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.",
)))
(build_list_for_group(&handouts, "Advanced"))
br {}
}
})
}),
}
}

View File

@@ -23,53 +23,55 @@ pub fn index() -> Page {
}, },
generate_html: Box::new(move |_page| { generate_html: Box::new(move |_page| {
html! { Box::pin(async {
h2 id="about" { "About" } html! {
h2 id="about" { "About" }
div { div {
img img
src=(Image_Cover::URL) src=(Image_Cover::URL)
style="float:left;margin:10px 10px 10px 10px;display:block;width:25%;" style="float:left;margin:10px 10px 10px 10px;display:block;width:25%;"
{} {}
div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" { 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" "Welcome, you've reached Mark's main page. Here you'll find"
" links to various projects I've worked on." " links to various projects I've worked on."
ul { ul {
li { (MangledBetaEmail {}) } li { (MangledBetaEmail {}) }
li { (MangledGoogleEmail {}) } li { (MangledGoogleEmail {}) }
li { li {
( (
FarLink( FarLink(
"https://github.com/rm-dr", "https://github.com/rm-dr",
html!( html!(
(FAIcon::Github) (FAIcon::Github)
"rm-dr" "rm-dr"
)
) )
) )
) }
}
li { li {
( (
FarLink( FarLink(
"https://git.betalupi.com", "https://git.betalupi.com",
html!( html!(
(FAIcon::Git) (FAIcon::Git)
"git.betalupi.com" "git.betalupi.com"
)
) )
) )
) }
} }
} }
br style="clear:both;" {}
} }
br style="clear:both;" {}
}
(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:
@@ -21,10 +25,3 @@ pub fn betalupi() -> Page {
Some(Image_Icon::URL.to_string()), Some(Image_Icon::URL.to_string()),
) )
} }
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,59 +18,65 @@ 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>> {
html! { Box::pin(async move {
(DOCTYPE) html! {
html { (DOCTYPE)
head { html {
meta charset="UTF" {} head {
meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {} meta charset="UTF" {}
meta content="text/html; charset=UTF-8" http-equiv="content-type" {} meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {}
meta property="og:type" content="website" {} meta content="text/html; charset=UTF-8" http-equiv="content-type" {}
meta property="og:type" content="website" {}
link rel="stylesheet" href=(Styles_Main::URL) {} link rel="stylesheet" href=(Styles_Main::URL) {}
(&page.meta) (&page.meta)
title { (PreEscaped(page.meta.title.clone())) } title { (PreEscaped(page.meta.title.clone())) }
} }
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" {}
div class = "footContainer" { div class = "footContainer" {
p { p {
"This site was built by hand using " "This site was built by hand using "
(FarLink("https://rust-lang.org", "Rust")) (FarLink("https://rust-lang.org", "Rust"))
", " ", "
(FarLink("https://maud.lambda.xyz", "Maud")) (FarLink("https://maud.lambda.xyz", "Maud"))
", " ", "
(FarLink("https://github.com/connorskees/grass", "Grass")) (FarLink("https://github.com/connorskees/grass", "Grass"))
", and " ", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum")) (FarLink("https://docs.rs/axum/latest/axum", "Axum"))
"." "."
}
} }
} }
} }
} }
} }
} }
} })
} }
#[test] #[test]