Compare commits

...

3 Commits

Author SHA1 Message Date
08586f0a7a README
Some checks failed
CI / Check typos (push) Failing after 9s
CI / Check links (push) Failing after 13s
CI / Clippy (push) Successful in 58s
CI / Build container (push) Has been cancelled
CI / Deploy on waypoint (push) Has been cancelled
CI / Build and test (push) Has been cancelled
2025-11-12 13:59:40 -08:00
6493476565 TTL 2025-11-12 13:59:40 -08:00
d5067ff381 404 2025-11-12 13:59:38 -08:00
9 changed files with 145 additions and 65 deletions

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
[`utoipa`]: https://docs.rs/utoipa/latest/utoipa/
[`axum`]: https://docs.rs/axum/latest/axum/
# Mark's webpage
This is the source code behind [betalupi.com](https://betalupi.com), featuring a very efficient mini web framework written from scratch in Rust. It uses...
- [Axum](https://github.com/tokio-rs/axum) as an http server
- [Maud](https://maud.lambda.xyz/) for html templates
- [Grass](https://github.com/connorskees/grass) to parse and compile [sass](https://sass-lang.com/)
- [markdown-it](https://github.com/markdown-it-rust/markdown-it) to convert md to html
## Overview & Arch:
- [`bin/webpage`](./crates/bin/webpage/): Simple cli that starts `service-webpage`
- [`lib/libservice`](./crates/lib/libservice): Provides the `Service` trait. A service is a group of http routes with an optional [`utoipa`] schema. \
This library decouples compiled binaries from the services they provide, and makes sure all services are self-contained.
- [`lib/page`](./crates/lib/page): Provides [PageServer], which builds an [`axum`] router that provides a caching and headers for resources served through http.
- Also provides [Servable], which is a trait for any resource that may be served.
- the [Page] servable serves html generated by a closure.
- the [StaticAsset] servable serves static assets (css, images, misc files), and provides transformation utilties for image assets (via [`pixel-transform`](./crates/lib/pixel-transform)).
- [`service/service-webpage`](./crates/service/service-webpage): A `Service` that runs a `PageServer` that provides the content on [betalupi.com](https://betalupi.com)

View File

@@ -93,9 +93,7 @@ impl Default for Page {
fn default() -> Self { fn default() -> Self {
Page { Page {
meta: Default::default(), meta: Default::default(),
html_ttl: Some(TimeDelta::seconds(60 * 60 * 24 * 30)), html_ttl: Some(TimeDelta::days(1)),
//css_ttl: Duration::from_secs(60 * 60 * 24 * 30),
//generate_css: None,
generate_html: Box::new(|_, _| Box::pin(async { html!() })), generate_html: Box::new(|_, _| Box::pin(async { html!() })),
immutable: true, immutable: true,
} }

View File

@@ -52,6 +52,29 @@ pub trait Servable: Send + Sync {
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>; ) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
} }
pub struct Default404 {}
impl Servable for Default404 {
fn render<'a>(
&'a self,
_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
Box::pin(async {
return Rendered {
code: StatusCode::NOT_FOUND,
body: RenderedBody::String("page not found".into()),
ttl: Some(TimeDelta::days(1)),
immutable: true,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
};
})
}
}
//
// MARK: server
//
pub struct PageServer { pub struct PageServer {
/// If true, expired pages will be rerendered before being sent to the user. /// 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 false, requests never trigger rerenders. We rely on the rerender task.
@@ -61,7 +84,9 @@ pub struct PageServer {
never_rerender_on_request: bool, never_rerender_on_request: bool,
/// Map of `{ route: page }` /// Map of `{ route: page }`
pages: Arc<Mutex<HashMap<String, Arc<dyn Servable>>>>, pages: Mutex<HashMap<String, Arc<dyn Servable>>>,
notfound: Mutex<Arc<dyn Servable>>,
/// Map of `{ route: (page data, expire time) }` /// Map of `{ route: (page data, expire time) }`
/// ///
@@ -75,12 +100,19 @@ impl PageServer {
let cache_size = NonZero::new(128).unwrap(); let cache_size = NonZero::new(128).unwrap();
Arc::new(Self { Arc::new(Self {
pages: Arc::new(Mutex::new(HashMap::new())), pages: Mutex::new(HashMap::new()),
page_cache: Mutex::new(LruCache::new(cache_size)), page_cache: Mutex::new(LruCache::new(cache_size)),
never_rerender_on_request: true, never_rerender_on_request: true,
notfound: Mutex::new(Arc::new(Default404 {})),
}) })
} }
/// Set this server's "not found" page
pub fn with_404<S: Servable + 'static>(&self, page: S) -> &Self {
*self.notfound.lock() = Arc::new(page);
self
}
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self { pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
#[expect(clippy::expect_used)] #[expect(clippy::expect_used)]
let route = route let route = route
@@ -102,23 +134,23 @@ impl PageServer {
reason: &'static str, reason: &'static str,
route: &str, route: &str,
ctx: RequestContext, ctx: RequestContext,
) -> Option<(Rendered, Option<DateTime<Utc>>)> { ) -> (Rendered, Option<DateTime<Utc>>) {
let now = Utc::now(); let now = Utc::now();
let start = Instant::now(); let start = Instant::now();
let page = match self.pages.lock().get(route) { let page = match self.pages.lock().get(route) {
Some(x) => x.clone(), Some(x) => x.clone(),
None => return None, None => self.notfound.lock().clone(),
}; };
trace!( trace!(
message = "Rendering page", message = "Rendering page",
route, route = route.to_owned(),
reason, reason,
lock_time_ms = start.elapsed().as_millis() lock_time_ms = start.elapsed().as_millis()
); );
let rendered = page.render(&ctx).await; let rendered = page.render(&ctx).await;
//let html = (self.render_page)(&page, &req_ctx).await.0;
let mut expires = None; let mut expires = None;
if let Some(ttl) = rendered.ttl { if let Some(ttl) = rendered.ttl {
@@ -129,8 +161,13 @@ impl PageServer {
} }
let elapsed = start.elapsed().as_millis(); let elapsed = start.elapsed().as_millis();
trace!(message = "Rendered page", route, reason, time_ms = elapsed); trace!(
return Some((rendered, expires)); message = "Rendered page",
route = route.to_owned(),
reason,
time_ms = elapsed
);
return (rendered, expires);
} }
async fn handler( async fn handler(
@@ -206,30 +243,7 @@ impl PageServer {
if html_expires.is_none() { if html_expires.is_none() {
cached = false; cached = false;
html_expires = match state.render_page("request", &route, ctx).await { html_expires = Some(state.render_page("request", &route, ctx).await);
Some(x) => Some(x.clone()),
None => {
trace!(
message = "Not found",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos()
);
return StatusCode::NOT_FOUND.into_response();
}
};
} }
#[expect(clippy::unwrap_used)] #[expect(clippy::unwrap_used)]

View File

@@ -81,9 +81,11 @@ body {
color: var(--fgColor); color: var(--fgColor);
} }
main { div.wrapper {
margin-top: 2ex; min-height: 100vh;
overflow-wrap: break-word; display: flex;
flex-direction: column;
justify-content: space-between;
} }
hr.footline { hr.footline {
@@ -92,18 +94,14 @@ hr.footline {
hr { hr {
border: 1pt dashed; border: 1pt dashed;
width: 100%;
} }
iframe { iframe {
max-width: 90%; max-width: 90%;
} }
.wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.footContainer { .footContainer {
padding-top: 0; padding-top: 0;

View File

@@ -262,7 +262,7 @@ pub fn handouts() -> Page {
br {} br {}
}; };
page_wrapper(&page.meta, inner).await page_wrapper(&page.meta, inner, true).await
}) })
}), }),
} }

View File

@@ -16,7 +16,7 @@ pub fn index() -> Page {
meta: PageMetadata { meta: PageMetadata {
title: "Betalupi: About".into(), title: "Betalupi: About".into(),
author: Some("Mark".into()), author: Some("Mark".into()),
description: Some("Description".into()), description: None,
image: Some("/assets/img/icon.png".to_owned()), image: Some("/assets/img/icon.png".to_owned()),
backlinks: Some(false), backlinks: Some(false),
}, },
@@ -74,7 +74,7 @@ pub fn index() -> Page {
(Markdown(include_str!("index.md"))) (Markdown(include_str!("index.md")))
}; };
page_wrapper(&page.meta, inner).await page_wrapper(&page.meta, inner, true).await
}) })
}), }),

View File

@@ -10,9 +10,11 @@ use crate::components::{
mod handouts; mod handouts;
mod index; mod index;
mod notfound;
pub use handouts::handouts; pub use handouts::handouts;
pub use index::index; pub use index::index;
pub use notfound::notfound;
pub fn links() -> Page { pub fn links() -> Page {
/* /*
@@ -67,7 +69,7 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
meta, meta,
immutable: true, immutable: true,
html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)), html_ttl: Some(TimeDelta::days(1)),
generate_html: Box::new(move |page, ctx| { generate_html: Box::new(move |page, ctx| {
let html = html.clone(); let html = html.clone();
@@ -80,7 +82,7 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
(html) (html)
}; };
page_wrapper(&page.meta, inner).await page_wrapper(&page.meta, inner, true).await
}) })
}), }),
} }
@@ -93,6 +95,7 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
pub fn page_wrapper<'a>( pub fn page_wrapper<'a>(
meta: &'a PageMetadata, meta: &'a PageMetadata,
inner: Markup, inner: Markup,
footer: bool,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move { Box::pin(async move {
html! { html! {
@@ -110,6 +113,9 @@ pub fn page_wrapper<'a>(
title { (PreEscaped(meta.title.clone())) } title { (PreEscaped(meta.title.clone())) }
// Use a small blurred placeholder while full-size images load.
// Requires no other special scripts or css, just add some tags
// to your <img>!
script { script {
(PreEscaped(" (PreEscaped("
window.onload = function() { window.onload = function() {
@@ -133,9 +139,17 @@ pub fn page_wrapper<'a>(
} }
body { body {
div class="wrapper" { main{
main { (inner) } div class="wrapper" style=(
// for 404 page. Margin makes it scroll.
match footer {
true => "margin-top:3ex;",
false =>""
}
) {
(inner)
@if footer {
footer { footer {
hr class = "footline" {} hr class = "footline" {}
div class = "footContainer" { div class = "footContainer" {
@@ -154,6 +168,7 @@ pub fn page_wrapper<'a>(
} }
} }
} }
}}
} }
} }
}) })

View File

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

View File

@@ -15,6 +15,7 @@ fn build_server() -> Arc<PageServer> {
#[expect(clippy::unwrap_used)] #[expect(clippy::unwrap_used)]
server server
.with_404(pages::notfound())
.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())