Compare commits

..

10 Commits

Author SHA1 Message Date
b6de727883 page rewrite
Some checks failed
CI / Check typos (push) Successful in 1m3s
CI / Check links (push) Failing after 1m14s
CI / Clippy (push) Successful in 1m43s
CI / Build and test (push) Successful in 1m31s
CI / Build container (push) Successful in 1m45s
CI / Deploy on waypoint (push) Failing after 1m23s
2025-11-16 12:58:20 -08:00
04d98462dd Return 404 for 404 page
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 13s
CI / Clippy (push) Successful in 54s
CI / Build and test (push) Successful in 1m20s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
2025-11-14 09:46:18 -08:00
991eb92562 Tweaks
Some checks failed
CI / Check typos (push) Successful in 28s
CI / Check links (push) Failing after 31s
CI / Clippy (push) Successful in 1m2s
CI / Build and test (push) Successful in 1m21s
CI / Build container (push) Successful in 1m5s
CI / Deploy on waypoint (push) Successful in 44s
2025-11-12 14:18:44 -08:00
529dfc468e README 2025-11-12 14:18:41 -08:00
6493476565 TTL 2025-11-12 13:59:40 -08:00
d5067ff381 404 2025-11-12 13:59:38 -08:00
532cfe58ba Minor tweaks
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Clippy (push) Successful in 59s
CI / Check links (push) Failing after 1m32s
CI / Build and test (push) Successful in 1m21s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
2025-11-09 21:20:47 -08:00
c13618e958 Transform images + placeholders
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 10s
CI / Clippy (push) Successful in 56s
CI / Build and test (push) Successful in 1m22s
CI / Build container (push) Successful in 1m4s
CI / Deploy on waypoint (push) Successful in 46s
2025-11-08 13:12:25 -08:00
1329539059 Add pixel-transform 2025-11-08 13:12:23 -08:00
e70170ee5b Merge asset and page server 2025-11-08 09:33:12 -08:00
40 changed files with 3247 additions and 1317 deletions

1006
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,14 +64,13 @@ type_complexity = "allow"
# #
[workspace.dependencies] [workspace.dependencies]
macro-assets = { path = "crates/macro/macro-assets" }
macro-sass = { path = "crates/macro/macro-sass" } macro-sass = { path = "crates/macro/macro-sass" }
assetserver = { path = "crates/lib/assetserver" }
libservice = { path = "crates/lib/libservice" } libservice = { path = "crates/lib/libservice" }
toolbox = { path = "crates/lib/toolbox" } toolbox = { path = "crates/lib/toolbox" }
page = { path = "crates/lib/page" } page = { path = "crates/lib/page" }
md-footnote = { path = "crates/lib/md-footnote" } md-footnote = { path = "crates/lib/md-footnote" }
md-dev = { path = "crates/lib/md-dev" } md-dev = { path = "crates/lib/md-dev" }
pixel-transform = { path = "crates/lib/pixel-transform" }
service-webpage = { path = "crates/service/service-webpage" } service-webpage = { path = "crates/service/service-webpage" }
@@ -81,6 +80,8 @@ service-webpage = { path = "crates/service/service-webpage" }
# #
axum = { version = "0.8.6", features = ["macros", "multipart"] } axum = { version = "0.8.6", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace", "compression-full"] } tower-http = { version = "0.6.6", features = ["trace", "compression-full"] }
tower = { version = "0.5.2" }
serde_urlencoded = { version = "0.7.1" }
utoipa = "5.4.0" utoipa = "5.4.0"
utoipa-swagger-ui = { version = "9.0.2", features = [ utoipa-swagger-ui = { version = "9.0.2", features = [
"axum", "axum",
@@ -94,6 +95,7 @@ emojis = "0.8.0"
reqwest = { version = "0.12.24", default-features = false, features = [ reqwest = { version = "0.12.24", default-features = false, features = [
"http2", "http2",
"rustls-tls", "rustls-tls",
"rustls-tls-webpki-roots", # Need to recompile to update
"cookies", "cookies",
"gzip", "gzip",
"stream", "stream",
@@ -144,6 +146,10 @@ chrono = "0.4.42"
lru = "0.16.2" lru = "0.16.2"
parking_lot = "0.12.5" parking_lot = "0.12.5"
lazy_static = "1.5.0" lazy_static = "1.5.0"
image = "0.25.8"
scraper = "0.24.0"
futures = "0.3.31"
tempfile = "3.23.0"
# md_* test utilities # md_* test utilities
prettydiff = "0.9.0" prettydiff = "0.9.0"

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
[utoipa]: https://docs.rs/utoipa/latest/utoipa/
[axum]: https://docs.rs/axum/latest/axum/
[betalupi.com]: https://betalupi.com
# Mark's webpage
This is the source code behind [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 parameters 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]

View File

@@ -1,8 +0,0 @@
[package]
name = "assetserver"
version = { workspace = true }
rust-version = { workspace = true }
edition = { workspace = true }
[lints]
workspace = true

View File

@@ -1,14 +0,0 @@
/// A static asset with compile-time embedded data.
pub trait Asset {
/// The common URL prefix for all assets (e.g., "/assets")
const URL_PREFIX: &'static str;
/// The specific URL path for this asset (e.g., "/logo.png")
const URL_POSTFIX: &'static str;
/// The full URL for this asset (e.g., "/assets/logo.png")
const URL: &'static str;
/// The embedded file contents as a byte slice
const BYTES: &'static [u8];
}

View File

@@ -8,13 +8,14 @@ edition = { workspace = true }
workspace = true workspace = true
[dependencies] [dependencies]
libservice = { workspace = true } toolbox = { workspace = true }
pixel-transform = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
maud = { workspace = true } maud = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
lru = { workspace = true } tower = { workspace = true }
tower-http = { workspace = true } serde_urlencoded = { workspace = true }

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
use axum::http::{HeaderMap, StatusCode};
use chrono::TimeDelta;
use pixel_transform::TransformerChain;
use std::{pin::Pin, str::FromStr};
use toolbox::mime::MimeType;
use tracing::{error, trace};
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
pub struct StaticAsset {
pub bytes: &'static [u8],
pub mime: MimeType,
}
impl Servable for StaticAsset {
fn head<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async {
let ttl = Some(TimeDelta::days(30));
let is_image = TransformerChain::mime_is_image(&self.mime);
let transform = match (is_image, ctx.query.get("t")) {
(false, _) | (_, None) => None,
(true, Some(x)) => match TransformerChain::from_str(x) {
Ok(x) => Some(x),
Err(_err) => {
return Rendered {
code: StatusCode::BAD_REQUEST,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
},
};
match transform {
Some(transform) => {
return Rendered {
code: StatusCode::OK,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(
transform
.output_mime(&self.mime)
.unwrap_or(self.mime.clone()),
),
};
}
None => {
return Rendered {
code: StatusCode::OK,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(self.mime.clone()),
};
}
}
})
}
fn render<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
Box::pin(async {
let ttl = Some(TimeDelta::days(30));
// Automatically provide transformation if this is an image
let is_image = TransformerChain::mime_is_image(&self.mime);
let transform = match (is_image, ctx.query.get("t")) {
(false, _) | (_, None) => None,
(true, Some(x)) => match TransformerChain::from_str(x) {
Ok(x) => Some(x),
Err(err) => {
return Rendered {
code: StatusCode::BAD_REQUEST,
body: RenderedBody::String(err),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
},
};
match transform {
Some(transform) => {
trace!(message = "Transforming image", ?transform);
let task = {
let mime = Some(self.mime.clone());
let bytes = self.bytes;
tokio::task::spawn_blocking(move || {
transform.transform_bytes(bytes, mime.as_ref())
})
};
let res = match task.await {
Ok(x) => x,
Err(error) => {
error!(message = "Error while transforming image", ?error);
return Rendered {
code: StatusCode::INTERNAL_SERVER_ERROR,
body: RenderedBody::String(format!(
"Error while transforming image: {error:?}"
)),
ttl: None,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
};
match res {
Ok((mime, bytes)) => {
return Rendered {
code: StatusCode::OK,
body: RenderedBody::Bytes(bytes),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(mime),
};
}
Err(err) => {
return Rendered {
code: StatusCode::INTERNAL_SERVER_ERROR,
body: RenderedBody::String(format!("{err}")),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
}
}
None => {
return Rendered {
code: StatusCode::OK,
body: RenderedBody::Static(self.bytes),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(self.mime.clone()),
};
}
}
})
}
}

View File

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

View File

@@ -1,13 +1,11 @@
use axum::http::{ use axum::http::{HeaderMap, StatusCode};
HeaderMap, HeaderValue, StatusCode,
header::{self},
};
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{Markup, Render, html}; use maud::{DOCTYPE, Markup, PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use std::pin::Pin; use std::{pin::Pin, sync::Arc};
use toolbox::mime::MimeType;
use crate::{Rendered, RequestContext, Servable}; use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
// //
// MARK: metadata // MARK: metadata
@@ -19,7 +17,6 @@ pub struct PageMetadata {
pub author: Option<String>, pub author: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub image: Option<String>, pub image: Option<String>,
pub backlinks: Option<bool>,
} }
impl Default for PageMetadata { impl Default for PageMetadata {
@@ -29,44 +26,18 @@ impl Default for PageMetadata {
author: None, author: None,
description: None, description: None,
image: None, image: None,
backlinks: None,
} }
} }
} }
impl Render for PageMetadata {
fn render(&self) -> Markup {
let empty = String::new();
let title = &self.title;
let author = &self.author.as_ref().unwrap_or(&empty);
let description = &self.description.as_ref().unwrap_or(&empty);
let image = &self.image.as_ref().unwrap_or(&empty);
html !(
meta property="og:site_name" content=(title) {}
meta name="title" content=(title) {}
meta property="og:title" content=(title) {}
meta property="twitter:title" content=(title) {}
meta name="author" content=(author) {}
meta name="description" content=(description) {}
meta property="og:description" content=(description) {}
meta property="twitter:description" content=(description) {}
meta content=(image) property="og:image" {}
link rel="shortcut icon" href=(image) type="image/x-icon" {}
)
}
}
// //
// MARK: page // MARK: page
// //
// Some HTML #[derive(Clone)]
pub struct Page { pub struct Page {
pub meta: PageMetadata, pub meta: PageMetadata,
pub immutable: bool,
/// 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. /// This controls the maximum age of a page shown to the user.
@@ -80,54 +51,259 @@ 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< pub generate_html: Arc<
dyn Send dyn Send
+ Sync + Sync
+ 'static
+ for<'a> Fn( + for<'a> Fn(
&'a Page, &'a Page,
&'a RequestContext, &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>, ) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>>,
>, >,
pub response_code: StatusCode,
pub scripts_inline: Vec<String>,
pub scripts_linked: Vec<String>,
pub styles_linked: Vec<String>,
pub styles_inline: Vec<String>,
/// `name`, `content` for extra `<meta>` tags
pub extra_meta: Vec<(String, String)>,
} }
impl Default for Page { impl Default for Page {
fn default() -> Self { fn default() -> Self {
Page { Page {
// No cache by default
html_ttl: None,
immutable: false,
meta: Default::default(), meta: Default::default(),
html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)), generate_html: Arc::new(|_, _| Box::pin(async { html!() })),
//css_ttl: Duration::from_secs(60 * 24 * 30), response_code: StatusCode::OK,
//generate_css: None, scripts_inline: Vec::new(),
generate_html: Box::new(|_, _| Box::pin(async { html!() })), scripts_linked: Vec::new(),
styles_inline: Vec::new(),
styles_linked: Vec::new(),
extra_meta: Vec::new(),
} }
} }
} }
impl Page { impl Page {
pub async fn generate_html(&self, ctx: &RequestContext) -> Markup { pub async fn generate_html(&self, ctx: &RenderContext) -> Markup {
(self.generate_html)(self, ctx).await (self.generate_html)(self, ctx).await
} }
pub fn immutable(mut self, immutable: bool) -> Self {
self.immutable = immutable;
self
}
pub fn html_ttl(mut self, html_ttl: Option<TimeDelta>) -> Self {
self.html_ttl = html_ttl;
self
}
pub fn response_code(mut self, response_code: StatusCode) -> Self {
self.response_code = response_code;
self
}
pub fn with_script_inline(mut self, script: impl Into<String>) -> Self {
self.scripts_inline.push(script.into());
self
}
pub fn with_script_linked(mut self, url: impl Into<String>) -> Self {
self.scripts_linked.push(url.into());
self
}
pub fn with_style_inline(mut self, style: impl Into<String>) -> Self {
self.styles_inline.push(style.into());
self
}
pub fn with_style_linked(mut self, url: impl Into<String>) -> Self {
self.styles_linked.push(url.into());
self
}
pub fn with_extra_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_meta.push((key.into(), value.into()));
self
}
} }
impl Servable for Page { impl Servable for Page {
fn render<'a>( fn head<'a>(
&'a self, &'a self,
ctx: &'a RequestContext, _ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async { Box::pin(async {
let mut headers = HeaderMap::with_capacity(3);
let html = self.generate_html(ctx).await;
headers.append(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
return Rendered { return Rendered {
code: StatusCode::OK, code: self.response_code,
headers, body: (),
body: html.0.into_bytes(),
ttl: self.html_ttl, ttl: self.html_ttl,
immutable: self.immutable,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
}; };
}) })
} }
fn render<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
Box::pin(async {
let inner_html = self.generate_html(ctx).await;
let html = html! {
(DOCTYPE)
html {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no";
meta content="text/html; charset=UTF-8" http-equiv="content-type";
meta property="og:type" content="website";
@for (name, content) in &self.extra_meta {
meta name=(name) content=(content);
}
//
// Metadata
//
title { (PreEscaped(self.meta.title.clone())) }
meta property="og:site_name" content=(self.meta.title);
meta name="title" content=(self.meta.title);
meta property="og:title" content=(self.meta.title);
meta property="twitter:title" content=(self.meta.title);
@if let Some(author) = &self.meta.author {
meta name="author" content=(author);
}
@if let Some(desc) = &self.meta.description {
meta name="description" content=(desc);
meta property="og:description" content=(desc);
meta property="twitter:description" content=(desc);
}
@if let Some(image) = &self.meta.image {
meta content=(image) property="og:image";
link rel="shortcut icon" href=(image) type="image/x-icon";
}
//
// Scripts & styles
//
@for script in &self.scripts_linked {
script src=(script) {}
}
@for style in &self.styles_linked {
link rel="stylesheet" type="text/css" href=(style);
}
@for script in &self.scripts_inline {
script { (PreEscaped(script)) }
}
@for style in &self.styles_inline {
style { (PreEscaped(style)) }
}
}
body { main { (inner_html) } }
}
};
return self.head(ctx).await.with_body(RenderedBody::Markup(html));
})
}
}
//
// MARK: template
//
pub struct PageTemplate {
pub immutable: bool,
pub html_ttl: Option<TimeDelta>,
pub response_code: StatusCode,
pub scripts_inline: &'static [&'static str],
pub scripts_linked: &'static [&'static str],
pub styles_inline: &'static [&'static str],
pub styles_linked: &'static [&'static str],
pub extra_meta: &'static [(&'static str, &'static str)],
}
impl Default for PageTemplate {
fn default() -> Self {
Self::const_default()
}
}
impl PageTemplate {
pub const fn const_default() -> Self {
Self {
html_ttl: Some(TimeDelta::days(1)),
immutable: true,
response_code: StatusCode::OK,
scripts_inline: &[],
scripts_linked: &[],
styles_inline: &[],
styles_linked: &[],
extra_meta: &[],
}
}
/// Create a new page using this template,
/// with the given metadata and renderer.
pub fn derive<
R: Send
+ Sync
+ 'static
+ for<'a> Fn(
&'a Page,
&'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>>,
>(
&self,
meta: PageMetadata,
generate_html: R,
) -> Page {
Page {
meta,
immutable: self.immutable,
html_ttl: self.html_ttl,
response_code: self.response_code,
scripts_inline: self
.scripts_inline
.iter()
.map(|x| (*x).to_owned())
.collect(),
scripts_linked: self
.scripts_linked
.iter()
.map(|x| (*x).to_owned())
.collect(),
styles_inline: self.styles_inline.iter().map(|x| (*x).to_owned()).collect(),
styles_linked: self.styles_linked.iter().map(|x| (*x).to_owned()).collect(),
extra_meta: self
.extra_meta
.iter()
.map(|(a, b)| ((*a).to_owned(), (*b).to_owned()))
.collect(),
generate_html: Arc::new(generate_html),
}
}
} }

View File

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

View File

@@ -1,258 +0,0 @@
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 lru::LruCache;
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;
use crate::{ClientInfo, RequestContext};
#[derive(Clone)]
pub struct Rendered {
pub code: StatusCode,
pub headers: HeaderMap,
pub body: Vec<u8>,
pub ttl: Option<TimeDelta>,
}
pub trait Servable: Send + Sync {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
}
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<dyn Servable>>>>,
/// Map of `{ route: (page data, expire time) }`
///
/// We use an LruCache for bounded memory usage.
page_cache: Mutex<LruCache<RequestContext, (Rendered, DateTime<Utc>)>>,
}
impl PageServer {
pub fn new() -> Arc<Self> {
#[expect(clippy::unwrap_used)]
let cache_size = NonZero::new(128).unwrap();
Arc::new(Self {
pages: Arc::new(Mutex::new(HashMap::new())),
page_cache: Mutex::new(LruCache::new(cache_size)),
never_rerender_on_request: true,
})
}
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
#[expect(clippy::expect_used)]
let route = route
.into()
.strip_prefix("/")
.expect("route must start with /")
.to_owned();
self.pages.lock().insert(route, Arc::new(page));
self
}
/// Re-render the page at `route`, regardless of cache state.
/// Does nothing if there is no page at `route`.
///
/// Returns the rendered page's content.
async fn render_page(
&self,
reason: &'static str,
route: &str,
ctx: RequestContext,
) -> Option<(Rendered, Option<DateTime<Utc>>)> {
let now = Utc::now();
let start = Instant::now();
let page = match self.pages.lock().get(route) {
Some(x) => x.clone(),
None => return None,
};
trace!(
message = "Rendering page",
route,
reason,
lock_time_ms = start.elapsed().as_millis()
);
let rendered = page.render(&ctx).await;
//let html = (self.render_page)(&page, &req_ctx).await.0;
let mut expires = None;
if let Some(ttl) = rendered.ttl {
expires = Some(now + ttl);
self.page_cache
.lock()
.put(ctx, (rendered.clone(), now + ttl));
}
let elapsed = start.elapsed().as_millis();
trace!(message = "Rendered page", route, reason, time_ms = elapsed);
return Some((rendered, expires));
}
async fn handler(
Path(route): Path<String>,
State(state): State<Arc<Self>>,
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap,
) -> Response {
let start = Instant::now();
let client_info = ClientInfo::from_headers(&headers);
let ua = headers
.get("user-agent")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
trace!(
message = "Serving route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
// Normalize url with redirect
if route.ends_with('/') || route.contains("//") || route.starts_with('/') {
let mut new_route = route.clone();
while new_route.contains("//") {
new_route = new_route.replace("//", "/");
}
let new_route = new_route.trim_matches('/');
trace!(
message = "Redirecting",
route,
new_route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
let mut headers = HeaderMap::with_capacity(2);
let new_route = match HeaderValue::from_str(&format!("/{new_route}")) {
Ok(x) => x,
Err(_) => {
// Be extra careful, this is user-provided data
return StatusCode::BAD_REQUEST.into_response();
}
};
headers.append(header::LOCATION, new_route);
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
return (StatusCode::PERMANENT_REDIRECT, headers).into_response();
}
let ctx = RequestContext {
client_info,
route: format!("/{route}"),
};
let now = Utc::now();
let mut html_expires = None;
// Get from cache, if available
if let Some((html, expires)) = state.page_cache.lock().get(&ctx)
&& (*expires > now || state.never_rerender_on_request)
{
html_expires = Some((html.clone(), Some(*expires)));
};
if html_expires.is_none() {
html_expires = match 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,
time_ns = start.elapsed().as_nanos()
);
return StatusCode::NOT_FOUND.into_response();
}
};
}
#[expect(clippy::unwrap_used)]
let (mut html, expires) = html_expires.unwrap();
let max_age = match expires {
Some(expires) => (expires - now).num_seconds().max(1),
None => 1,
};
#[expect(clippy::unwrap_used)]
html.headers.insert(
header::CACHE_CONTROL,
// immutable; public/private
HeaderValue::from_str(&format!("immutable, public, max-age={}", max_age)).unwrap(),
);
html.headers
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
time_ns = start.elapsed().as_nanos()
);
return (html.code, html.headers, html.body).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)
}
}

View File

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

View File

@@ -0,0 +1,16 @@
[package]
name = "pixel-transform"
version = { workspace = true }
rust-version = { workspace = true }
edition = { workspace = true }
[lints]
workspace = true
[dependencies]
toolbox = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
strum = { workspace = true }

View File

@@ -0,0 +1,159 @@
use image::{DynamicImage, ImageFormat};
use serde::{Deserialize, Deserializer, de};
use std::{fmt::Display, hash::Hash, io::Cursor, str::FromStr};
use thiserror::Error;
use toolbox::mime::MimeType;
use crate::transformers::{ImageTransformer, TransformerEnum};
#[derive(Debug, Error)]
pub enum TransformBytesError {
#[error("{0} is not a valid image type")]
NotAnImage(String),
#[error("error while processing image")]
ImageError(#[from] image::ImageError),
}
#[derive(Debug, Clone)]
pub struct TransformerChain {
pub steps: Vec<TransformerEnum>,
}
impl TransformerChain {
#[inline]
pub fn mime_is_image(mime: &MimeType) -> bool {
ImageFormat::from_mime_type(mime.to_string()).is_some()
}
pub fn transform_image(&self, mut image: DynamicImage) -> DynamicImage {
for step in &self.steps {
match step {
TransformerEnum::Format { .. } => {}
TransformerEnum::MaxDim(t) => t.transform(&mut image),
TransformerEnum::Crop(t) => t.transform(&mut image),
}
}
return image;
}
pub fn output_mime(&self, input_mime: &MimeType) -> Option<MimeType> {
let mime = self
.steps
.last()
.and_then(|x| match x {
TransformerEnum::Format { format } => Some(MimeType::from(format.to_mime_type())),
_ => None,
})
.unwrap_or(input_mime.clone());
let fmt = ImageFormat::from_mime_type(mime.to_string());
fmt.map(|_| mime)
}
pub fn transform_bytes(
&self,
image_bytes: &[u8],
image_format: Option<&MimeType>,
) -> Result<(MimeType, Vec<u8>), TransformBytesError> {
let format: ImageFormat = match image_format {
Some(x) => ImageFormat::from_mime_type(x.to_string())
.ok_or(TransformBytesError::NotAnImage(x.to_string()))?,
None => image::guess_format(image_bytes)?,
};
let out_format = self
.steps
.last()
.and_then(|x| match x {
TransformerEnum::Format { format } => Some(format),
_ => None,
})
.unwrap_or(&format);
let img = image::load_from_memory_with_format(image_bytes, format)?;
let img = self.transform_image(img);
let out_mime = MimeType::from(out_format.to_mime_type());
let mut out_bytes = Cursor::new(Vec::new());
img.write_to(&mut out_bytes, *out_format)?;
return Ok((out_mime, out_bytes.into_inner()));
}
}
impl FromStr for TransformerChain {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let steps_str = s.split(";");
let mut steps = Vec::new();
for s in steps_str {
let s = s.trim();
if s.is_empty() {
continue;
}
let step = s.parse();
match step {
Ok(x) => steps.push(x),
Err(msg) => return Err(format!("invalid step `{s}`: {msg}")),
}
}
let n_format = steps
.iter()
.filter(|x| matches!(x, TransformerEnum::Format { .. }))
.count();
if n_format > 2 {
return Err("provide at most one format()".to_owned());
}
if n_format == 1 && !matches!(steps.last(), Some(TransformerEnum::Format { .. })) {
return Err("format() must be last".to_owned());
}
return Ok(Self { steps });
}
}
impl<'de> Deserialize<'de> for TransformerChain {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::from_str(&s).map_err(de::Error::custom)
}
}
impl Display for TransformerChain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut first = true;
for step in &self.steps {
if first {
write!(f, "{step}")?;
first = false
} else {
write!(f, ";{step}")?;
}
}
return Ok(());
}
}
impl PartialEq for TransformerChain {
fn eq(&self, other: &Self) -> bool {
self.to_string() == other.to_string()
}
}
impl Eq for TransformerChain {}
impl Hash for TransformerChain {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.to_string().hash(state);
}
}

View File

@@ -0,0 +1,6 @@
mod pixeldim;
pub mod transformers;
mod chain;
pub use chain::*;

View File

@@ -0,0 +1,68 @@
use serde::{Deserialize, Deserializer};
use std::fmt;
use std::str::FromStr;
// TODO: parse -, + (100vw - 10px)
// TODO: parse 100vw [min] 10
// TODO: parse 100vw [max] 10
#[derive(Debug, Clone, PartialEq)]
pub enum PixelDim {
Pixels(u32),
WidthPercent(f32),
HeightPercent(f32),
}
impl FromStr for PixelDim {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let numeric_end = s.find(|c: char| !c.is_ascii_digit() && c != '.');
let (quantity, unit) = numeric_end.map(|x| s.split_at(x)).unwrap_or((s, "px"));
let quantity = quantity.trim();
let unit = unit.trim();
match unit {
"vw" => Ok(PixelDim::WidthPercent(
quantity
.parse()
.map_err(|_err| format!("invalid quantity {quantity}"))?,
)),
"vh" => Ok(PixelDim::HeightPercent(
quantity
.parse()
.map_err(|_err| format!("invalid quantity {quantity}"))?,
)),
"px" => Ok(PixelDim::Pixels(
quantity
.parse()
.map_err(|_err| format!("invalid quantity {quantity}"))?,
)),
_ => Err(format!("invalid unit {unit}")),
}
}
}
impl<'de> Deserialize<'de> for PixelDim {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
FromStr::from_str(&s).map_err(serde::de::Error::custom)
}
}
impl fmt::Display for PixelDim {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PixelDim::Pixels(px) => write!(f, "{px}"),
PixelDim::WidthPercent(p) => write!(f, "{p:.2}vw"),
PixelDim::HeightPercent(p) => write!(f, "{p:.2}vh"),
}
}
}

View File

@@ -0,0 +1,184 @@
use std::{fmt::Display, str::FromStr};
use image::DynamicImage;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString};
use crate::{pixeldim::PixelDim, transformers::ImageTransformer};
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, Serialize, Deserialize, Display)]
pub enum Direction {
#[serde(rename = "n")]
#[strum(to_string = "n")]
#[strum(serialize = "north")]
North,
#[serde(rename = "e")]
#[strum(serialize = "e")]
#[strum(serialize = "east")]
East,
#[serde(rename = "s")]
#[strum(serialize = "s")]
#[strum(serialize = "south")]
South,
#[serde(rename = "w")]
#[strum(to_string = "w")]
#[strum(serialize = "west")]
West,
#[serde(rename = "c")]
#[strum(serialize = "c")]
#[strum(serialize = "center")]
Center,
#[serde(rename = "ne")]
#[strum(serialize = "ne")]
#[strum(serialize = "northeast")]
NorthEast,
#[serde(rename = "se")]
#[strum(serialize = "se")]
#[strum(serialize = "southeast")]
SouthEast,
#[serde(rename = "nw")]
#[strum(serialize = "nw")]
#[strum(serialize = "northwest")]
NorthWest,
#[serde(rename = "sw")]
#[strum(serialize = "sw")]
#[strum(serialize = "southwest")]
SouthWest,
}
/// Crop an image to the given size.
/// - does not crop width if `w` is greater than image width
/// - does not crop height if `h` is greater than image height
/// - does nothing if `w` or `h` are less than or equal to zero.
#[derive(Debug, Clone, PartialEq)]
pub struct CropTransformer {
w: PixelDim,
h: PixelDim,
float: Direction,
}
impl CropTransformer {
pub fn new(w: PixelDim, h: PixelDim, float: Direction) -> Self {
Self { w, h, float }
}
fn crop_dim(&self, img_width: u32, img_height: u32) -> (u32, u32) {
let crop_width = match self.w {
PixelDim::Pixels(w) => w,
PixelDim::WidthPercent(pct) => ((img_width as f32) * pct / 100.0) as u32,
PixelDim::HeightPercent(pct) => ((img_height as f32) * pct / 100.0) as u32,
};
let crop_height = match self.h {
PixelDim::Pixels(h) => h,
PixelDim::WidthPercent(pct) => ((img_width as f32) * pct / 100.0) as u32,
PixelDim::HeightPercent(pct) => ((img_height as f32) * pct / 100.0) as u32,
};
(crop_width, crop_height)
}
#[expect(clippy::integer_division)]
fn crop_pos(
&self,
img_width: u32,
img_height: u32,
crop_width: u32,
crop_height: u32,
) -> (u32, u32) {
match self.float {
Direction::North => {
let x = (img_width - crop_width) / 2;
let y = 0;
(x, y)
}
Direction::East => {
let x = img_width - crop_width;
let y = (img_height - crop_height) / 2;
(x, y)
}
Direction::South => {
let x = (img_width - crop_width) / 2;
let y = img_height - crop_height;
(x, y)
}
Direction::West => {
let x = 0;
let y = (img_height - crop_height) / 2;
(x, y)
}
Direction::Center => {
let x = (img_width - crop_width) / 2;
let y = (img_height - crop_height) / 2;
(x, y)
}
Direction::NorthEast => {
let x = img_width - crop_width;
let y = 0;
(x, y)
}
Direction::SouthEast => {
let x = img_width - crop_width;
let y = img_height - crop_height;
(x, y)
}
Direction::NorthWest => {
let x = 0;
let y = 0;
(x, y)
}
Direction::SouthWest => {
let x = 0;
let y = img_height - crop_height;
(x, y)
}
}
}
}
impl Display for CropTransformer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "crop({},{},{})", self.w, self.h, self.float)
}
}
impl ImageTransformer for CropTransformer {
fn parse_args(args: &str) -> Result<Self, String> {
let args: Vec<&str> = args.split(",").collect();
if args.len() != 3 {
return Err(format!("expected 3 args, got {}", args.len()));
}
let w = args[0].trim().parse::<PixelDim>()?;
let h = args[1].trim().parse::<PixelDim>()?;
let direction = args[2].trim();
let direction = Direction::from_str(direction)
.map_err(|_err| format!("invalid direction {direction}"))?;
Ok(Self {
w,
h,
float: direction,
})
}
fn transform(&self, input: &mut DynamicImage) {
let (img_width, img_height) = (input.width(), input.height());
let (crop_width, crop_height) = self.crop_dim(img_width, img_height);
if (crop_width < img_width || crop_height < img_height) && crop_width > 0 && crop_height > 0
{
let (x, y) = self.crop_pos(img_width, img_height, crop_width, crop_height);
*input = input.crop(x, y, crop_width, crop_height);
}
}
}

View File

@@ -0,0 +1,82 @@
use std::fmt::Display;
use image::{DynamicImage, imageops::FilterType};
use crate::{pixeldim::PixelDim, transformers::ImageTransformer};
#[derive(Debug, Clone, PartialEq)]
pub struct MaxDimTransformer {
w: PixelDim,
h: PixelDim,
}
impl MaxDimTransformer {
pub fn new(w: PixelDim, h: PixelDim) -> Self {
Self { w, h }
}
fn target_dim(&self, img_width: u32, img_height: u32) -> (u32, u32) {
let max_width = match self.w {
PixelDim::Pixels(w) => Some(w),
PixelDim::WidthPercent(pct) => Some(((img_width as f32) * pct / 100.0) as u32),
PixelDim::HeightPercent(_) => None,
};
let max_height = match self.h {
PixelDim::Pixels(h) => Some(h),
PixelDim::HeightPercent(pct) => Some(((img_height as f32) * pct / 100.0) as u32),
PixelDim::WidthPercent(_) => None,
};
if max_width.map(|x| img_width <= x).unwrap_or(true)
&& max_height.map(|x| img_height <= x).unwrap_or(true)
{
return (img_width, img_height);
}
let width_ratio = max_width
.map(|x| x as f32 / img_width as f32)
.unwrap_or(1.0);
let height_ratio = max_height
.map(|x| x as f32 / img_height as f32)
.unwrap_or(1.0);
let ratio = width_ratio.min(height_ratio);
(
(img_width as f32 * ratio) as u32,
(img_height as f32 * ratio) as u32,
)
}
}
impl Display for MaxDimTransformer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "maxdim({},{})", self.w, self.h)
}
}
impl ImageTransformer for MaxDimTransformer {
fn parse_args(args: &str) -> Result<Self, String> {
let args: Vec<&str> = args.split(",").collect();
if args.len() != 2 {
return Err(format!("expected 2 args, got {}", args.len()));
}
let w = args[0].parse::<PixelDim>()?;
let h = args[1].parse::<PixelDim>()?;
Ok(Self { w, h })
}
fn transform(&self, input: &mut DynamicImage) {
let (img_width, img_height) = (input.width(), input.height());
let (target_width, target_height) = self.target_dim(img_width, img_height);
// Only resize if needed
if target_width != img_width || target_height != img_height {
*input = input.resize(target_width, target_height, FilterType::Lanczos3);
}
}
}

View File

@@ -0,0 +1,165 @@
use image::{DynamicImage, ImageFormat};
use std::fmt;
use std::fmt::{Debug, Display};
use std::str::FromStr;
mod crop;
pub use crop::*;
mod maxdim;
pub use maxdim::*;
pub trait ImageTransformer
where
Self: PartialEq,
Self: Sized + Clone,
Self: Display + Debug,
{
/// Transform the given image in place
fn transform(&self, input: &mut DynamicImage);
/// Parse an arg string.
///
/// `name({arg_string})`
fn parse_args(args: &str) -> Result<Self, String>;
}
use serde::{Deserialize, Deserializer};
/// An enum of all [`ImageTransformer`]s
#[derive(Debug, Clone, PartialEq)]
pub enum TransformerEnum {
/// Usage: `maxdim(w, h)`
///
/// Scale the image so its width is smaller than `w`
/// and its height is smaller than `h`. Aspect ratio is preserved.
///
/// To only limit the size of one dimension, use `vw` or `vh`.
/// For example, `maxdim(50,100vh)` will not limit width.
MaxDim(MaxDimTransformer),
/// Usage: `crop(w, h, float)`
///
/// Crop the image to at most `w` by `h` pixels,
/// floating the crop area in the specified direction.
///
/// Directions are one of:
/// - Cardinal: n,e,s,w
/// - Diagonal: ne,nw,se,sw,
/// - Centered: c
///
/// Examples:
/// - `crop(100vw, 50)` gets the top 50 pixels of the image \
/// (or fewer, if the image's height is smaller than 50)
///
/// To only limit the size of one dimension, use `vw` or `vh`.
/// For example, `maxdim(50,100vh)` will not limit width.
Crop(CropTransformer),
/// Usage: `format(format)`
///
/// Transcode the image to the given format.
/// This step must be last, and cannot be provided
/// more than once.
///
/// Valid formats:
/// - bmp
/// - gif
/// - ico
/// - jpeg or jpg
/// - png
/// - qoi
/// - webp
///
/// Example:
/// - `format(png)`
///
/// When transcoding an animated gif, the first frame is taken
/// and all others are thrown away. This happens even if we
/// transcode from a gif to a gif.
Format { format: ImageFormat },
}
impl FromStr for TransformerEnum {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim();
let (name, args) = {
let name_len = match s.find('(') {
Some(x) => x + 1,
None => {
return Err(format!(
"invalid transformation {s}. Must look like name(args)."
));
}
};
let mut balance = 1;
let mut end = name_len;
for i in s[name_len..].bytes() {
match i {
b')' => balance -= 1,
b'(' => balance += 1,
_ => {}
}
if balance == 0 {
break;
}
end += 1;
}
if balance != 0 {
return Err(format!("mismatched parenthesis in {s}"));
}
let name = s[0..name_len - 1].trim();
let args = s[name_len..end].trim();
let trail = s[end + 1..].trim();
if !trail.is_empty() {
return Err(format!(
"invalid transformation {s}. Must look like name(args)."
));
}
(name, args)
};
match name {
"maxdim" => Ok(Self::MaxDim(MaxDimTransformer::parse_args(args)?)),
"crop" => Ok(Self::Crop(CropTransformer::parse_args(args)?)),
"format" => Ok(TransformerEnum::Format {
format: ImageFormat::from_extension(args)
.ok_or(format!("invalid image format {args}"))?,
}),
_ => Err(format!("unknown transformation {name}")),
}
}
}
impl<'de> Deserialize<'de> for TransformerEnum {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl Display for TransformerEnum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TransformerEnum::MaxDim(x) => Display::fmt(x, f),
TransformerEnum::Crop(x) => Display::fmt(x, f),
TransformerEnum::Format { format } => {
write!(f, "format({})", format.extensions_str()[0])
}
}
}
}

View File

@@ -64,6 +64,8 @@ pub enum MimeType {
Jpg, Jpg,
/// Portable Network Graphics (image/png) /// Portable Network Graphics (image/png)
Png, Png,
/// Quite ok Image Format
Qoi,
/// Scalable Vector Graphics (image/svg+xml) /// Scalable Vector Graphics (image/svg+xml)
Svg, Svg,
/// Tagged Image File Format (image/tiff) /// Tagged Image File Format (image/tiff)
@@ -217,7 +219,9 @@ impl<'de> Deserialize<'de> for MimeType {
} }
} }
//
// MARK: misc // MARK: misc
//
impl Default for MimeType { impl Default for MimeType {
fn default() -> Self { fn default() -> Self {
@@ -243,6 +247,27 @@ impl From<&MimeType> for String {
} }
} }
//
// MARK: fromstr
//
impl MimeType {
/// Parse a mimetype from a string that may contain
/// whitespace or ";" parameters.
///
/// Parameters are discarded, write your own parser if you need them.
pub fn from_header(s: &str) -> Result<Self, <Self as FromStr>::Err> {
let s = s.trim();
let semi = s.find(';').unwrap_or(s.len());
let space = s.find(' ').unwrap_or(s.len());
let limit = semi.min(space);
let s = &s[0..limit];
let s = s.trim();
return Self::from_str(s);
}
}
impl FromStr for MimeType { impl FromStr for MimeType {
type Err = std::convert::Infallible; type Err = std::convert::Infallible;
@@ -251,7 +276,7 @@ impl FromStr for MimeType {
Ok(match s { Ok(match s {
"application/octet-stream" => Self::Blob, "application/octet-stream" => Self::Blob,
// MARK: Audio // Audio
"audio/aac" => Self::Aac, "audio/aac" => Self::Aac,
"audio/flac" => Self::Flac, "audio/flac" => Self::Flac,
"audio/midi" | "audio/x-midi" => Self::Midi, "audio/midi" | "audio/x-midi" => Self::Midi,
@@ -260,7 +285,7 @@ impl FromStr for MimeType {
"audio/wav" => Self::Wav, "audio/wav" => Self::Wav,
"audio/webm" => Self::Weba, "audio/webm" => Self::Weba,
// MARK: Video // Video
"video/x-msvideo" => Self::Avi, "video/x-msvideo" => Self::Avi,
"video/mp4" => Self::Mp4, "video/mp4" => Self::Mp4,
"video/mpeg" => Self::Mpeg, "video/mpeg" => Self::Mpeg,
@@ -270,7 +295,7 @@ impl FromStr for MimeType {
"video/3gpp" => Self::ThreeGp, "video/3gpp" => Self::ThreeGp,
"video/3gpp2" => Self::ThreeG2, "video/3gpp2" => Self::ThreeG2,
// MARK: Images // Images
"image/apng" => Self::Apng, "image/apng" => Self::Apng,
"image/avif" => Self::Avif, "image/avif" => Self::Avif,
"image/bmp" => Self::Bmp, "image/bmp" => Self::Bmp,
@@ -281,8 +306,9 @@ impl FromStr for MimeType {
"image/svg+xml" => Self::Svg, "image/svg+xml" => Self::Svg,
"image/tiff" => Self::Tiff, "image/tiff" => Self::Tiff,
"image/webp" => Self::Webp, "image/webp" => Self::Webp,
"image/qoi" => Self::Qoi,
// MARK: Text // Text
"text/plain" => Self::Text, "text/plain" => Self::Text,
"text/css" => Self::Css, "text/css" => Self::Css,
"text/csv" => Self::Csv, "text/csv" => Self::Csv,
@@ -292,11 +318,11 @@ impl FromStr for MimeType {
"application/ld+json" => Self::JsonLd, "application/ld+json" => Self::JsonLd,
"application/xml" | "text/xml" => Self::Xml, "application/xml" | "text/xml" => Self::Xml,
// MARK: Documents // Documents
"application/pdf" => Self::Pdf, "application/pdf" => Self::Pdf,
"application/rtf" => Self::Rtf, "application/rtf" => Self::Rtf,
// MARK: Archives // Archives
"application/x-freearc" => Self::Arc, "application/x-freearc" => Self::Arc,
"application/x-bzip" => Self::Bz, "application/x-bzip" => Self::Bz,
"application/x-bzip2" => Self::Bz2, "application/x-bzip2" => Self::Bz2,
@@ -308,14 +334,14 @@ impl FromStr for MimeType {
"application/x-tar" => Self::Tar, "application/x-tar" => Self::Tar,
"application/zip" | "application/x-zip-compressed" => Self::Zip, "application/zip" | "application/x-zip-compressed" => Self::Zip,
// MARK: Fonts // Fonts
"application/vnd.ms-fontobject" => Self::Eot, "application/vnd.ms-fontobject" => Self::Eot,
"font/otf" => Self::Otf, "font/otf" => Self::Otf,
"font/ttf" => Self::Ttf, "font/ttf" => Self::Ttf,
"font/woff" => Self::Woff, "font/woff" => Self::Woff,
"font/woff2" => Self::Woff2, "font/woff2" => Self::Woff2,
// MARK: Applications // Applications
"application/x-abiword" => Self::Abiword, "application/x-abiword" => Self::Abiword,
"application/vnd.amazon.ebook" => Self::Azw, "application/vnd.amazon.ebook" => Self::Azw,
"application/x-cdf" => Self::Cda, "application/x-cdf" => Self::Cda,
@@ -348,6 +374,10 @@ impl FromStr for MimeType {
} }
} }
//
// MARK: display
//
impl Display for MimeType { impl Display for MimeType {
/// Get a string representation of this mimetype. /// Get a string representation of this mimetype.
/// ///
@@ -368,7 +398,7 @@ impl Display for MimeType {
match self { match self {
Self::Blob => write!(f, "application/octet-stream"), Self::Blob => write!(f, "application/octet-stream"),
// MARK: Audio // Audio
Self::Aac => write!(f, "audio/aac"), Self::Aac => write!(f, "audio/aac"),
Self::Flac => write!(f, "audio/flac"), Self::Flac => write!(f, "audio/flac"),
Self::Midi => write!(f, "audio/midi"), Self::Midi => write!(f, "audio/midi"),
@@ -378,7 +408,7 @@ impl Display for MimeType {
Self::Wav => write!(f, "audio/wav"), Self::Wav => write!(f, "audio/wav"),
Self::Weba => write!(f, "audio/webm"), Self::Weba => write!(f, "audio/webm"),
// MARK: Video // Video
Self::Avi => write!(f, "video/x-msvideo"), Self::Avi => write!(f, "video/x-msvideo"),
Self::Mp4 => write!(f, "video/mp4"), Self::Mp4 => write!(f, "video/mp4"),
Self::Mpeg => write!(f, "video/mpeg"), Self::Mpeg => write!(f, "video/mpeg"),
@@ -388,7 +418,7 @@ impl Display for MimeType {
Self::ThreeGp => write!(f, "video/3gpp"), Self::ThreeGp => write!(f, "video/3gpp"),
Self::ThreeG2 => write!(f, "video/3gpp2"), Self::ThreeG2 => write!(f, "video/3gpp2"),
// MARK: Images // Images
Self::Apng => write!(f, "image/apng"), Self::Apng => write!(f, "image/apng"),
Self::Avif => write!(f, "image/avif"), Self::Avif => write!(f, "image/avif"),
Self::Bmp => write!(f, "image/bmp"), Self::Bmp => write!(f, "image/bmp"),
@@ -399,8 +429,9 @@ impl Display for MimeType {
Self::Svg => write!(f, "image/svg+xml"), Self::Svg => write!(f, "image/svg+xml"),
Self::Tiff => write!(f, "image/tiff"), Self::Tiff => write!(f, "image/tiff"),
Self::Webp => write!(f, "image/webp"), Self::Webp => write!(f, "image/webp"),
Self::Qoi => write!(f, "image/qoi"),
// MARK: Text // Text
Self::Text => write!(f, "text/plain"), Self::Text => write!(f, "text/plain"),
Self::Css => write!(f, "text/css"), Self::Css => write!(f, "text/css"),
Self::Csv => write!(f, "text/csv"), Self::Csv => write!(f, "text/csv"),
@@ -410,11 +441,11 @@ impl Display for MimeType {
Self::JsonLd => write!(f, "application/ld+json"), Self::JsonLd => write!(f, "application/ld+json"),
Self::Xml => write!(f, "application/xml"), Self::Xml => write!(f, "application/xml"),
// MARK: Documents // Documents
Self::Pdf => write!(f, "application/pdf"), Self::Pdf => write!(f, "application/pdf"),
Self::Rtf => write!(f, "application/rtf"), Self::Rtf => write!(f, "application/rtf"),
// MARK: Archives // Archives
Self::Arc => write!(f, "application/x-freearc"), Self::Arc => write!(f, "application/x-freearc"),
Self::Bz => write!(f, "application/x-bzip"), Self::Bz => write!(f, "application/x-bzip"),
Self::Bz2 => write!(f, "application/x-bzip2"), Self::Bz2 => write!(f, "application/x-bzip2"),
@@ -426,14 +457,14 @@ impl Display for MimeType {
Self::Tar => write!(f, "application/x-tar"), Self::Tar => write!(f, "application/x-tar"),
Self::Zip => write!(f, "application/zip"), Self::Zip => write!(f, "application/zip"),
// MARK: Fonts // Fonts
Self::Eot => write!(f, "application/vnd.ms-fontobject"), Self::Eot => write!(f, "application/vnd.ms-fontobject"),
Self::Otf => write!(f, "font/otf"), Self::Otf => write!(f, "font/otf"),
Self::Ttf => write!(f, "font/ttf"), Self::Ttf => write!(f, "font/ttf"),
Self::Woff => write!(f, "font/woff"), Self::Woff => write!(f, "font/woff"),
Self::Woff2 => write!(f, "font/woff2"), Self::Woff2 => write!(f, "font/woff2"),
// MARK: Applications // Applications
Self::Abiword => write!(f, "application/x-abiword"), Self::Abiword => write!(f, "application/x-abiword"),
Self::Azw => write!(f, "application/vnd.amazon.ebook"), Self::Azw => write!(f, "application/vnd.amazon.ebook"),
Self::Cda => write!(f, "application/x-cdf"), Self::Cda => write!(f, "application/x-cdf"),
@@ -471,13 +502,15 @@ impl Display for MimeType {
} }
impl MimeType { impl MimeType {
// Must match `From<String>` above //
// MARK: from extension
//
/// Try to guess a file's mime type from its extension. /// Try to guess a file's mime type from its extension.
/// `ext` should NOT start with a dot. /// `ext` should NOT start with a dot.
pub fn from_extension(ext: &str) -> Option<Self> { pub fn from_extension(ext: &str) -> Option<Self> {
Some(match ext { Some(match ext {
// MARK: Audio // Audio
"aac" => Self::Aac, "aac" => Self::Aac,
"flac" => Self::Flac, "flac" => Self::Flac,
"mid" | "midi" => Self::Midi, "mid" | "midi" => Self::Midi,
@@ -487,7 +520,7 @@ impl MimeType {
"wav" => Self::Wav, "wav" => Self::Wav,
"weba" => Self::Weba, "weba" => Self::Weba,
// MARK: Video // Video
"avi" => Self::Avi, "avi" => Self::Avi,
"mp4" => Self::Mp4, "mp4" => Self::Mp4,
"mpeg" => Self::Mpeg, "mpeg" => Self::Mpeg,
@@ -497,7 +530,7 @@ impl MimeType {
"3gp" => Self::ThreeGp, "3gp" => Self::ThreeGp,
"3g2" => Self::ThreeG2, "3g2" => Self::ThreeG2,
// MARK: Images // Images
"apng" => Self::Apng, "apng" => Self::Apng,
"avif" => Self::Avif, "avif" => Self::Avif,
"bmp" => Self::Bmp, "bmp" => Self::Bmp,
@@ -508,8 +541,9 @@ impl MimeType {
"svg" => Self::Svg, "svg" => Self::Svg,
"tif" | "tiff" => Self::Tiff, "tif" | "tiff" => Self::Tiff,
"webp" => Self::Webp, "webp" => Self::Webp,
"qoi" => Self::Qoi,
// MARK: Text // Text
"txt" => Self::Text, "txt" => Self::Text,
"css" => Self::Css, "css" => Self::Css,
"csv" => Self::Csv, "csv" => Self::Csv,
@@ -519,11 +553,11 @@ impl MimeType {
"jsonld" => Self::JsonLd, "jsonld" => Self::JsonLd,
"xml" => Self::Xml, "xml" => Self::Xml,
// MARK: Documents // Documents
"pdf" => Self::Pdf, "pdf" => Self::Pdf,
"rtf" => Self::Rtf, "rtf" => Self::Rtf,
// MARK: Archives // Archives
"arc" => Self::Arc, "arc" => Self::Arc,
"bz" => Self::Bz, "bz" => Self::Bz,
"bz2" => Self::Bz2, "bz2" => Self::Bz2,
@@ -535,14 +569,14 @@ impl MimeType {
"tar" => Self::Tar, "tar" => Self::Tar,
"zip" => Self::Zip, "zip" => Self::Zip,
// MARK: Fonts // Fonts
"eot" => Self::Eot, "eot" => Self::Eot,
"otf" => Self::Otf, "otf" => Self::Otf,
"ttf" => Self::Ttf, "ttf" => Self::Ttf,
"woff" => Self::Woff, "woff" => Self::Woff,
"woff2" => Self::Woff2, "woff2" => Self::Woff2,
// MARK: Applications // Applications
"abw" => Self::Abiword, "abw" => Self::Abiword,
"azw" => Self::Azw, "azw" => Self::Azw,
"cda" => Self::Cda, "cda" => Self::Cda,
@@ -569,100 +603,209 @@ impl MimeType {
}) })
} }
//
// MARK: to extension
//
/// Get the extension we use for files with this type. /// Get the extension we use for files with this type.
/// Includes a dot. Might be the empty string. /// Never includes a dot.
pub fn extension(&self) -> &str { pub fn extension(&self) -> Option<&'static str> {
match self { match self {
Self::Blob => "", Self::Blob => None,
Self::Other(_) => "", Self::Other(_) => None,
// MARK: Audio // Audio
Self::Aac => ".aac", Self::Aac => Some("aac"),
Self::Flac => ".flac", Self::Flac => Some("flac"),
Self::Midi => ".midi", Self::Midi => Some("midi"),
Self::Mp3 => ".mp3", Self::Mp3 => Some("mp3"),
Self::Oga => ".oga", Self::Oga => Some("oga"),
Self::Opus => ".opus", Self::Opus => Some("opus"),
Self::Wav => ".wav", Self::Wav => Some("wav"),
Self::Weba => ".weba", Self::Weba => Some("weba"),
// MARK: Video // Video
Self::Avi => ".avi", Self::Avi => Some("avi"),
Self::Mp4 => ".mp4", Self::Mp4 => Some("mp4"),
Self::Mpeg => ".mpeg", Self::Mpeg => Some("mpeg"),
Self::Ogv => ".ogv", Self::Ogv => Some("ogv"),
Self::Ts => ".ts", Self::Ts => Some("ts"),
Self::WebmVideo => ".webm", Self::WebmVideo => Some("webm"),
Self::ThreeGp => ".3gp", Self::ThreeGp => Some("3gp"),
Self::ThreeG2 => ".3g2", Self::ThreeG2 => Some("3g2"),
// MARK: Images // Images
Self::Apng => ".apng", Self::Apng => Some("apng"),
Self::Avif => ".avif", Self::Avif => Some("avif"),
Self::Bmp => ".bmp", Self::Bmp => Some("bmp"),
Self::Gif => ".gif", Self::Gif => Some("gif"),
Self::Ico => ".ico", Self::Ico => Some("ico"),
Self::Jpg => ".jpg", Self::Jpg => Some("jpg"),
Self::Png => ".png", Self::Png => Some("png"),
Self::Svg => ".svg", Self::Svg => Some("svg"),
Self::Tiff => ".tiff", Self::Tiff => Some("tiff"),
Self::Webp => ".webp", Self::Webp => Some("webp"),
Self::Qoi => Some("qoi"),
// MARK: Text // Text
Self::Text => ".txt", Self::Text => Some("txt"),
Self::Css => ".css", Self::Css => Some("css"),
Self::Csv => ".csv", Self::Csv => Some("csv"),
Self::Html => ".html", Self::Html => Some("html"),
Self::Javascript => ".js", Self::Javascript => Some("js"),
Self::Json => ".json", Self::Json => Some("json"),
Self::JsonLd => ".jsonld", Self::JsonLd => Some("jsonld"),
Self::Xml => ".xml", Self::Xml => Some("xml"),
// MARK: Documents // Documents
Self::Pdf => ".pdf", Self::Pdf => Some("pdf"),
Self::Rtf => ".rtf", Self::Rtf => Some("rtf"),
// MARK: Archives // Archives
Self::Arc => ".arc", Self::Arc => Some("arc"),
Self::Bz => ".bz", Self::Bz => Some("bz"),
Self::Bz2 => ".bz2", Self::Bz2 => Some("bz2"),
Self::Gz => ".gz", Self::Gz => Some("gz"),
Self::Jar => ".jar", Self::Jar => Some("jar"),
Self::Ogg => ".ogx", Self::Ogg => Some("ogx"),
Self::Rar => ".rar", Self::Rar => Some("rar"),
Self::SevenZ => ".7z", Self::SevenZ => Some("7z"),
Self::Tar => ".tar", Self::Tar => Some("tar"),
Self::Zip => ".zip", Self::Zip => Some("zip"),
// MARK: Fonts // Fonts
Self::Eot => ".eot", Self::Eot => Some("eot"),
Self::Otf => ".otf", Self::Otf => Some("otf"),
Self::Ttf => ".ttf", Self::Ttf => Some("ttf"),
Self::Woff => ".woff", Self::Woff => Some("woff"),
Self::Woff2 => ".woff2", Self::Woff2 => Some("woff2"),
// MARK: Applications // Applications
Self::Abiword => ".abw", Self::Abiword => Some("abw"),
Self::Azw => ".azw", Self::Azw => Some("azw"),
Self::Cda => ".cda", Self::Cda => Some("cda"),
Self::Csh => ".csh", Self::Csh => Some("csh"),
Self::Doc => ".doc", Self::Doc => Some("doc"),
Self::Docx => ".docx", Self::Docx => Some("docx"),
Self::Epub => ".epub", Self::Epub => Some("epub"),
Self::Ics => ".ics", Self::Ics => Some("ics"),
Self::Mpkg => ".mpkg", Self::Mpkg => Some("mpkg"),
Self::Odp => ".odp", Self::Odp => Some("odp"),
Self::Ods => ".ods", Self::Ods => Some("ods"),
Self::Odt => ".odt", Self::Odt => Some("odt"),
Self::Php => ".php", Self::Php => Some("php"),
Self::Ppt => ".ppt", Self::Ppt => Some("ppt"),
Self::Pptx => ".pptx", Self::Pptx => Some("pptx"),
Self::Sh => ".sh", Self::Sh => Some("sh"),
Self::Vsd => ".vsd", Self::Vsd => Some("vsd"),
Self::Xhtml => ".xhtml", Self::Xhtml => Some("xhtml"),
Self::Xls => ".xls", Self::Xls => Some("xls"),
Self::Xlsx => ".xlsx", Self::Xlsx => Some("xlsx"),
Self::Xul => ".xul", Self::Xul => Some("xul"),
}
}
//
// MARK: is_text
//
/// Returns true if this MIME type is always plain text.
pub fn is_text(&self) -> bool {
match self {
// Text types
Self::Text => true,
Self::Css => true,
Self::Csv => true,
Self::Html => true,
Self::Javascript => true,
Self::Json => true,
Self::JsonLd => true,
Self::Xml => true,
Self::Svg => true,
Self::Ics => true,
Self::Xhtml => true,
// Script types
Self::Csh => true,
Self::Php => true,
Self::Sh => true,
// All other types are not plain text
Self::Other(_) => false,
Self::Blob => false,
// Audio
Self::Aac => false,
Self::Flac => false,
Self::Midi => false,
Self::Mp3 => false,
Self::Oga => false,
Self::Opus => false,
Self::Wav => false,
Self::Weba => false,
// Video
Self::Avi => false,
Self::Mp4 => false,
Self::Mpeg => false,
Self::Ogv => false,
Self::Ts => false,
Self::WebmVideo => false,
Self::ThreeGp => false,
Self::ThreeG2 => false,
// Images
Self::Apng => false,
Self::Avif => false,
Self::Bmp => false,
Self::Gif => false,
Self::Ico => false,
Self::Jpg => false,
Self::Png => false,
Self::Qoi => false,
Self::Tiff => false,
Self::Webp => false,
// Documents
Self::Pdf => false,
Self::Rtf => false,
// Archives
Self::Arc => false,
Self::Bz => false,
Self::Bz2 => false,
Self::Gz => false,
Self::Jar => false,
Self::Ogg => false,
Self::Rar => false,
Self::SevenZ => false,
Self::Tar => false,
Self::Zip => false,
// Fonts
Self::Eot => false,
Self::Otf => false,
Self::Ttf => false,
Self::Woff => false,
Self::Woff2 => false,
// Applications
Self::Abiword => false,
Self::Azw => false,
Self::Cda => false,
Self::Doc => false,
Self::Docx => false,
Self::Epub => false,
Self::Mpkg => false,
Self::Odp => false,
Self::Ods => false,
Self::Odt => false,
Self::Ppt => false,
Self::Pptx => false,
Self::Vsd => false,
Self::Xls => false,
Self::Xlsx => false,
Self::Xul => false,
} }
} }
} }

View File

@@ -1,15 +0,0 @@
[package]
name = "macro-assets"
version = { workspace = true }
rust-version = { workspace = true }
edition = { workspace = true }
[lints]
workspace = true
[lib]
proc-macro = true
[dependencies]
syn = { workspace = true }
quote = { workspace = true }

View File

@@ -1,309 +0,0 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{
Expr, Ident, LitStr, Result, Token, braced,
parse::{Parse, ParseStream},
parse_macro_input,
};
/// A macro for generating static asset handlers with compile-time embedding.
///
/// This macro generates:
/// - Individual structs for each asset that implement the `assetserver::Asset` trait
/// - Compile-time embedding of asset files using `include_bytes!`
/// - Optionally, an Axum router function that serves all assets
///
/// # Syntax
///
/// ```notrust
/// assets! {
/// prefix: "/assets"
/// router: router_function_name()
///
/// AssetName {
/// source: "path/to/file.ext",
/// target: "/public-url.ext"
/// }
///
/// AnotherAsset {
/// source: "path/to/another.ext",
/// target: "/another-url.ext"
/// }
/// }
/// ```
///
/// # Arguments
///
/// - `prefix`: The URL prefix for all assets (e.g., "/assets")
/// - `router`: (Optional) The name of a function to generate that returns `(&'static str, Router<()>)`
/// with routes for all assets
/// - Asset blocks: Each block defines an asset with:
/// - A name (identifier) for the generated struct
/// - `source`: The file system path to the asset (relative to the current file)
/// - `target`: The URL path where the asset will be served
///
/// # Generated Code
///
/// For each asset, the macro generates:
/// - A struct with the specified name
/// - An `assetserver::Asset` trait implementation containing:
/// - `URL_PREFIX`: The common prefix for all assets
/// - `URL`: The specific URL path for this asset
/// - `BYTES`: The embedded file contents as a byte slice
/// - Documentation showing the original asset definition
///
/// If `router` is specified, also generates a function that returns an Axum router
/// with all assets mounted at their target URLs.
///
/// # Example
///
/// ```notrust
/// assets! {
/// prefix: "/static"
/// router: static_router()
///
/// Logo {
/// source: "../images/logo.png",
/// target: "/logo.png"
/// }
/// }
/// ```
///
/// This generates structs implementing `assetserver::Asset` and optionally a router function:
///
/// ```notrust
/// pub fn static_router() -> (&'static str, ::axum::Router<()>) {
/// let router = ::axum::Router::new()
/// .route(Logo::URL, ::axum::routing::get(|| async {
/// (::axum::http::StatusCode::OK, Logo::BYTES)
/// }));
/// ("/static", router)
/// }
/// ```
#[proc_macro]
pub fn assets(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as AssetsInput);
let prefix = &input.prefix;
let asset_impls = input.assets.iter().map(|asset| {
let name = &asset.name;
let source = &asset.source;
let target = &asset.target;
// Generate documentation showing the original asset definition
let doc = format!(
"This is an `asset!`\n```notrust\n{} {{\n\tsource: \"{:?}\",\n\ttarget: \"{}\"\n}}\n```",
name, source, target
);
quote! {
#[expect(clippy::allow_attributes)]
#[allow(non_camel_case_types)]
#[doc = #doc]
pub struct #name {}
impl ::assetserver::Asset for #name {
const URL_PREFIX: &'static str = #prefix;
const URL_POSTFIX: &'static str = #target;
const URL: &'static str = concat!(#prefix, #target);
const BYTES: &'static [u8] = #source;
}
}
});
// Generate the router function if specified
let router_fn = if let Some(router_name) = &input.router {
let route_definitions = input.assets.iter().map(|asset| {
let name = &asset.name;
let headers = asset
.headers
.as_ref()
.map(|x| quote! { #x })
.unwrap_or(quote! { [] });
quote! {
.route(#name::URL_POSTFIX, ::axum::routing::get(|| async {
(
::axum::http::StatusCode::OK,
#headers,
#name::BYTES
)
}))
}
});
let router_doc = format!(
"Generated router function that serves {} asset(s) with prefix \"{}\"",
input.assets.len(),
prefix
);
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)*
.layer(compression);
(#prefix, router)
}
}
} else {
quote! {}
};
let expanded = quote! {
#(#asset_impls)*
#router_fn
};
TokenStream::from(expanded)
}
/// Represents the complete input to the `assets!` macro
struct AssetsInput {
prefix: String,
router: Option<Ident>,
assets: Vec<AssetDefinition>,
}
/// Represents a single asset definition within the macro
struct AssetDefinition {
name: Ident,
source: Expr,
target: String,
headers: Option<Expr>,
}
impl Parse for AssetsInput {
fn parse(input: ParseStream<'_>) -> Result<Self> {
// Parse "prefix:"
let _prefix_ident: Ident = input.parse()?;
let _colon: Token![:] = input.parse()?;
let prefix_lit: LitStr = input.parse()?;
let prefix = prefix_lit.value();
// Try to parse optional "router:" parameter
let router = if input.peek(Ident) {
let peek_ident: Ident = input.fork().parse()?;
if peek_ident == "router" {
let _router_ident: Ident = input.parse()?;
let _colon: Token![:] = input.parse()?;
let router_name: Ident = input.parse()?;
// Parse the parentheses after the function name
let _paren_content;
syn::parenthesized!(_paren_content in input);
Some(router_name)
} else {
None
}
} else {
None
};
let mut assets = Vec::new();
// Parse asset definitions until we reach the end
while !input.is_empty() {
let asset = input.parse::<AssetDefinition>()?;
assets.push(asset);
}
Ok(AssetsInput {
prefix,
router,
assets,
})
}
}
impl Parse for AssetDefinition {
fn parse(input: ParseStream<'_>) -> Result<Self> {
// Parse the asset name
let name: Ident = input.parse()?;
// Parse the braced content
let content;
braced!(content in input);
// Parse fields in any order
let mut source: Option<Expr> = None;
let mut target: Option<String> = None;
let mut headers: Option<Expr> = None;
while !content.is_empty() {
// Parse field name
let field_name: Ident = content.parse()?;
let _colon: Token![:] = content.parse()?;
// Parse field value based on name
match field_name.to_string().as_str() {
"source" => {
if source.is_some() {
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",
));
}
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",
));
}
headers = Some(content.parse()?);
}
_ => {
return Err(syn::Error::new(
field_name.span(),
format!(
"unknown field '{}', expected 'source', 'target', or 'headers'",
field_name
),
));
}
}
// Parse comma if not at end
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
}
// 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'"))?;
Ok(AssetDefinition {
name,
source,
target,
headers,
})
}
}

View File

@@ -9,9 +9,7 @@ workspace = true
[dependencies] [dependencies]
libservice = { workspace = true } libservice = { workspace = true }
macro-assets = { workspace = true }
macro-sass = { workspace = true } macro-sass = { workspace = true }
assetserver = { workspace = true }
toolbox = { workspace = true } toolbox = { workspace = true }
page = { workspace = true } page = { workspace = true }
@@ -29,5 +27,5 @@ lazy_static = { workspace = true }
toml = { workspace = true } toml = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tower-http = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tower-http = { workspace = true }

View File

@@ -6,6 +6,7 @@ img {
border-radius: 15px; border-radius: 15px;
border: solid .2rem transparent; border: solid .2rem transparent;
transition: 150ms; transition: 150ms;
image-rendering: pixelated;
} }
img:hover { img:hover {

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

@@ -1,9 +1,8 @@
use lazy_static::lazy_static; use lazy_static::lazy_static;
use markdown_it::generics::inline::full_link; use markdown_it::generics::inline::full_link;
use markdown_it::{MarkdownIt, Node}; use markdown_it::{MarkdownIt, Node};
use maud::{Markup, PreEscaped, Render, html}; use maud::{Markup, PreEscaped, Render};
use page::RequestContext; use page::servable::PageMetadata;
use page::page::{Page, PageMetadata};
use crate::components::md::emote::InlineEmote; use crate::components::md::emote::InlineEmote;
use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter}; use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter};
@@ -86,10 +85,6 @@ impl Markdown<'_> {
} }
} }
//
// MARK: helpers
//
/// Try to read page metadata from a markdown file's frontmatter. /// Try to read page metadata from a markdown file's frontmatter.
/// - returns `none` if there is no frontmatter /// - returns `none` if there is no frontmatter
/// - returns an error if we fail to parse frontmatter /// - returns an error if we fail to parse frontmatter
@@ -101,33 +96,3 @@ pub fn meta_from_markdown(root_node: &Node) -> Result<Option<PageMetadata>, toml
.map(|x| toml::from_str::<PageMetadata>(&x.content)) .map(|x| toml::from_str::<PageMetadata>(&x.content))
.map_or(Ok(None), |v| v.map(Some)) .map_or(Ok(None), |v| v.map(Some))
} }
pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> {
let mut last = None;
let mut backlinks = vec![("/", "home")];
if page.meta.backlinks.unwrap_or(false) {
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
last = segments.pop();
let mut end = 0;
for s in segments {
end += s.len();
backlinks.push((&ctx.route[0..=end], s));
end += 1; // trailing slash
}
}
last.map(|last| {
html! {
div {
@for (url, text) in backlinks {
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
"/"
}
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
}
}
})
}

View File

@@ -23,12 +23,12 @@ A snippet of the [_Endless Sky_][es] map is below.
<br/> <br/>
**In other words:** Try finding a `.com` domain that... **In other words:** try finding a `.com` domain that...
- Isn't already taken - Isn't already taken
- Doesn't sound awful - Doesn't sound awful
- Isn't owned by a scalper that's selling it for $300" - Isn't owned by a scalper that's selling it for $300
<br/> <br/>
<img alt="betalupi map" src="/assets/img/betalupi.png"></img> <img class="img-placeholder" src="/assets/img/betalupi.png?t=maxdim(50,50)" data-large="/assets/img/betalupi.png" style="width:100%;height=10rem;"></img>

View File

@@ -13,8 +13,6 @@ arguably the best math circle in the western world. We teach students mathematic
far beyond the regular school curriculum, much like [AOPS](https://artofproblemsolving.com) far beyond the regular school curriculum, much like [AOPS](https://artofproblemsolving.com)
and the [BMC](https://mathcircle.berkeley.edu). and the [BMC](https://mathcircle.berkeley.edu).
<br></br>
{{color(--pink, "For my students:")}} \ {{color(--pink, "For my students:")}} \
Don't look at solutions we haven't discussed, Don't look at solutions we haven't discussed,
and don't start any handouts before class. That spoils all the fun! and don't start any handouts before class. That spoils all the fun!
@@ -36,7 +34,6 @@ 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).\ 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). \ Some are written in LaTeX, some are in [Typst](https://typst.app). \
The latter is vastly superior. The latter is vastly superior.
<br></br> <br></br>
<hr></hr> <hr style="margin:5rem 0 5rem 0;"></hr>
<br></br> <br></br>

View File

@@ -4,21 +4,19 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use assetserver::Asset;
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use page::{DeviceType, RequestContext, page::Page}; use page::{DeviceType, RenderContext, servable::Page};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Deserialize; use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::{ use crate::{
components::{ components::{
md::{Markdown, backlinks, meta_from_markdown}, md::{Markdown, meta_from_markdown},
misc::FarLink, misc::FarLink,
}, },
pages::page_wrapper, pages::{MAIN_TEMPLATE, backlinks, footer},
routes::assets::Image_Icon,
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -115,11 +113,7 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
return Ok(res); return Ok(res);
} }
fn build_list_for_group( fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &RenderContext) -> Markup {
handouts: &[HandoutEntry],
group: &str,
req_ctx: &RequestContext,
) -> Markup {
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile; let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
if mobile { if mobile {
@@ -194,18 +188,26 @@ pub fn handouts() -> Page {
let mut meta = meta_from_markdown(&md).unwrap().unwrap(); let mut meta = meta_from_markdown(&md).unwrap().unwrap();
if meta.image.is_none() { if meta.image.is_none() {
meta.image = Some(Image_Icon::URL.to_owned()); meta.image = Some("/assets/img/icon.png".to_owned());
} }
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
Page { MAIN_TEMPLATE
meta, .derive(meta, move |page, ctx| {
html_ttl: Some(TimeDelta::seconds(300)), let html = html.clone();
generate_html: Box::new(move |page, ctx| {
let html = html.clone(); // TODO: find a way to not clone here
let index = index.clone(); let index = index.clone();
render(html, index, page, ctx)
})
.html_ttl(Some(TimeDelta::seconds(300)))
}
fn render<'a>(
html: Markup,
index: Arc<CachedRequest<Result<Vec<HandoutEntry>, reqwest::Error>>>,
_page: &'a Page,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
Box::pin(async move { Box::pin(async move {
let handouts = index.get().await; let handouts = index.get().await;
@@ -235,8 +237,9 @@ pub fn handouts() -> Page {
Err(_) => fallback, Err(_) => fallback,
}; };
let inner = html! { html! {
@if let Some(backlinks) = backlinks(page, ctx) { div class="wrapper" style="margin-top:3ex;" {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks) (backlinks)
} }
@@ -261,10 +264,8 @@ pub fn handouts() -> Page {
))) )))
(advanced) (advanced)
br {} br {}
}; (footer())
page_wrapper(&page.meta, inner).await
})
}),
} }
}
})
} }

View File

@@ -5,11 +5,11 @@ Also see [what's a "betalupi?"](/whats-a-betalupi)
- [Handouts](/handouts): Math circle lessons I've written - [Handouts](/handouts): Math circle lessons I've written
- [Links](/links): Interesting parts of the internet - [Links](/links): Interesting parts of the internet
<hr style="margin-top: 8rem; margin-bottom: 8rem"/> <hr style="margin-top: 5rem; margin-bottom: 5rem"/>
## Projects ## Projects
- **RedoxOS**, a general-purpose, microkernel-based operating system written in Rust. _{{color(--grey, "[enthusiast]")}} - **RedoxOS**, a general-purpose, microkernel-based operating system written in Rust. _{{color(--grey, "[enthusiast]")}}_
- {{color(--grey, "Status: ")}} {{color(--yellow, "Passive.")}} - {{color(--grey, "Status: ")}} {{color(--yellow, "Passive.")}}
- {{color(--grey, "Website: ")}} [:fa-link: redox-os.org](https://www.redox-os.org/) - {{color(--grey, "Website: ")}} [:fa-link: redox-os.org](https://www.redox-os.org/)

View File

@@ -1,6 +1,9 @@
use assetserver::Asset; use maud::{Markup, html};
use maud::html; use page::{
use page::page::{Page, PageMetadata}; RenderContext,
servable::{Page, PageMetadata},
};
use std::pin::Pin;
use crate::{ use crate::{
components::{ components::{
@@ -9,29 +12,36 @@ use crate::{
md::Markdown, md::Markdown,
misc::FarLink, misc::FarLink,
}, },
pages::page_wrapper, pages::{MAIN_TEMPLATE, footer},
routes::assets::{Image_Cover, Image_Icon},
}; };
pub fn index() -> Page { pub fn index() -> Page {
Page { MAIN_TEMPLATE.derive(
meta: PageMetadata { 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(Image_Icon::URL.into()), image: Some("/assets/img/icon.png".to_owned()),
backlinks: Some(false),
}, },
render,
)
}
generate_html: Box::new(move |page, _ctx| { fn render<'a>(
_page: &'a Page,
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
Box::pin(async { Box::pin(async {
let inner = html! { html! {
div class="wrapper" style="margin-top:3ex;" {
h2 id="about" { "About" } h2 id="about" { "About" }
div { div {
img img
src=(Image_Cover::URL) class="img-placeholder"
style="float:left;margin:10px 10px 10px 10px;display:block;width:25%;" src="/assets/img/cover-small.jpg?t=maxdim(20,20)"
data-large="/assets/img/cover-small.jpg"
style="image-rendering:pixelated;float:left;margin:10px;display:block;width:25%;"
{} {}
div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" { div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" {
@@ -71,12 +81,9 @@ pub fn index() -> Page {
} }
(Markdown(include_str!("index.md"))) (Markdown(include_str!("index.md")))
};
page_wrapper(&page.meta, inner).await (footer())
})
}),
..Default::default()
} }
}
})
} }

View File

@@ -1,22 +1,23 @@
use assetserver::Asset;
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{DOCTYPE, Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use page::page::{Page, PageMetadata}; use page::{
use std::pin::Pin; RenderContext,
servable::{Page, PageMetadata, PageTemplate},
};
use crate::{ use crate::components::{
components::{ fa::FAIcon,
md::{Markdown, backlinks, meta_from_markdown}, md::{Markdown, meta_from_markdown},
misc::FarLink, misc::FarLink,
},
routes::assets::{Image_Icon, Styles_Main},
}; };
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 {
/* /*
@@ -26,20 +27,23 @@ pub fn links() -> Page {
http://www.3dprintmath.com/ 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("/assets/img/icon.png".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_owned()), Some("/assets/img/icon.png".to_owned()),
) )
} }
pub fn htwah_typesetting() -> Page { pub fn htwah_typesetting() -> Page {
page_from_markdown( page_from_markdown(
include_str!("htwah-typesetting.md"), include_str!("htwah-typesetting.md"),
Some(Image_Icon::URL.to_owned()), Some("/assets/img/icon.png".to_owned()),
) )
} }
@@ -64,75 +68,119 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
Page { MAIN_TEMPLATE
meta, .derive(meta, move |_page, ctx| {
html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)),
generate_html: Box::new(move |page, ctx| {
let html = html.clone(); let html = html.clone();
Box::pin(async move { Box::pin(async move {
let inner = html! { html! {
@if let Some(backlinks) = backlinks(page, ctx) { div class="wrapper" style="margin-top:3ex;" {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks) (backlinks)
} }
(html) (html)
};
page_wrapper(&page.meta, inner).await (footer())
})
}),
} }
}
})
})
.html_ttl(Some(TimeDelta::days(1)))
.immutable(true)
} }
// //
// MARK: wrapper // MARK: components
// //
pub fn page_wrapper<'a>( const MAIN_TEMPLATE: PageTemplate = PageTemplate {
meta: &'a PageMetadata, // Order matters, base htmx goes first
inner: Markup, scripts_linked: &["/assets/htmx.js", "/assets/htmx-json.js"],
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move {
html! {
(DOCTYPE)
html {
head {
meta charset="UTF" {}
meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {}
meta content="text/html; charset=UTF-8" http-equiv="content-type" {}
meta property="og:type" content="website" {}
link rel="stylesheet" href=(Styles_Main::URL) {} // TODO: use htmx for this
scripts_inline: &["
window.onload = function() {
var imgs = document.querySelectorAll('.img-placeholder');
(&meta) imgs.forEach(img => {
title { (PreEscaped(meta.title.clone())) } img.style.border = 'none';
img.style.filter = 'blur(10px)';
img.style.transition = 'filter 0.3s';
var lg = new Image();
lg.src = img.dataset.large;
lg.onload = function () {
img.src = img.dataset.large;
img.style.filter = 'blur(0px)';
};
})
}
"],
styles_inline: &[],
styles_linked: &["/assets/css/main.css"],
extra_meta: &[(
"viewport",
"width=device-width,initial-scale=1,user-scalable=no",
)],
..PageTemplate::const_default()
};
pub fn backlinks(ctx: &RenderContext) -> Option<Markup> {
let mut backlinks = vec![("/", "home")];
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
let last = segments.pop();
let mut end = 0;
for s in segments {
end += s.len();
backlinks.push((&ctx.route[0..=end], s));
end += 1; // trailing slash
} }
body { last.map(|last| {
div class="wrapper" { html! {
main { (inner) } div {
@for (url, text) in backlinks {
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
"/"
}
footer { span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
hr class = "footline" {} }
}
})
}
pub fn footer() -> Markup {
html!(
footer style="margin-top:10rem;" {
hr class = "footline";
div class = "footContainer" { div class = "footContainer" {
p { p {
"This site was built by hand using " "This site was built by hand with "
(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"))
", and " ", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum")) (FarLink("https://docs.rs/axum/latest/axum", "Axum"))
"." ". "
(
FarLink(
"https://git.betalupi.com/Mark/webpage",
html!(
(FAIcon::Git)
"Source here!"
)
)
)
} }
} }
} }
} )
}
}
}
})
} }

View File

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

View File

@@ -1,206 +0,0 @@
use assetserver::Asset;
use axum::http::header;
use macro_assets::assets;
use macro_sass::sass;
use toolbox::mime::MimeType;
assets! {
prefix: "/assets"
router: asset_router()
//
// MARK: styles
//
Styles_Main {
source: sass!("css/main.scss").as_bytes(),
target: "/css/main.css",
headers: [
(header::CONTENT_TYPE, "text/css")
]
}
//
// MARK: images
//
Image_Cover {
source: include_bytes!("../../assets/images/cover-small.jpg"),
target: "/img/face.jpg",
headers: [
(header::CONTENT_TYPE, "image/jpg")
]
}
Image_Betalupi {
source: include_bytes!("../../assets/images/betalupi-map.png"),
target: "/img/betalupi.png",
headers: [
(header::CONTENT_TYPE, "image/png")
]
}
Image_Icon {
source: include_bytes!("../../assets/images/icon.png"),
target: "/img/icon.png",
headers: [
(header::CONTENT_TYPE, "image/png")
]
}
//
// MARK: fonts
//
FiraCode_Bold_woff2 {
source: include_bytes!("../../assets/fonts/fira/FiraCode-Bold.woff2"),
target: "/fonts/FiraCode-Bold.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
FiraCode_Light_woff2 {
source: include_bytes!("../../assets/fonts/fira/FiraCode-Light.woff2"),
target: "/fonts/FiraCode-Light.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
FiraCode_Medium_woff2 {
source: include_bytes!("../../assets/fonts/fira/FiraCode-Medium.woff2"),
target: "/fonts/FiraCode-Medium.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
FiraCode_Regular_woff2 {
source: include_bytes!("../../assets/fonts/fira/FiraCode-Regular.woff2"),
target: "/fonts/FiraCode-Regular.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
FiraCode_SemiBold_woff2 {
source: include_bytes!("../../assets/fonts/fira/FiraCode-SemiBold.woff2"),
target: "/fonts/FiraCode-SemiBold.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
FiraCode_VF_woff2 {
source: include_bytes!("../../assets/fonts/fira/FiraCode-VF.woff2"),
target: "/fonts/FiraCode-VF.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
//
// MARK: icons
//
Fa_Brands_woff2 {
source: include_bytes!("../../assets/fonts/fa/fa-brands-400.woff2"),
target: "/fonts/fa/fa-brands-400.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
Fa_Regular_woff2 {
source: include_bytes!("../../assets/fonts/fa/fa-regular-400.woff2"),
target: "/fonts/fa/fa-regular-400.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
Fa_Solid_woff2 {
source: include_bytes!("../../assets/fonts/fa/fa-solid-900.woff2"),
target: "/fonts/fa/fa-solid-900.woff2",
headers: [
(header::CONTENT_TYPE, "application/font-woff2")
]
}
Fa_Brands_ttf {
source: include_bytes!("../../assets/fonts/fa/fa-brands-400.ttf"),
target: "/fonts/fa/fa-brands-400.ttf",
headers: [
(header::CONTENT_TYPE, "application/font-ttf")
]
}
Fa_Regular_ttf {
source: include_bytes!("../../assets/fonts/fa/fa-regular-400.ttf"),
target: "/fonts/fa/fa-regular-400.ttf",
headers: [
(header::CONTENT_TYPE, "application/font-ttf")
]
}
Fa_Solid_ttf {
source: include_bytes!("../../assets/fonts/fa/fa-solid-900.ttf"),
target: "/fonts/fa/fa-solid-900.ttf",
headers: [
(header::CONTENT_TYPE, "application/font-ttf")
]
}
//
// MARK: htwah
//
Htwah_Definitions {
source: include_bytes!("../../assets/htwah/definitions.pdf"),
target: "/htwah/definitions.pdf",
headers: [
(header::CONTENT_TYPE, MimeType::Pdf.to_string())
]
}
Htwah_Numbering {
source: include_bytes!("../../assets/htwah/numbering.pdf"),
target: "/htwah/numbering.pdf",
headers: [
(header::CONTENT_TYPE, MimeType::Pdf.to_string())
]
}
Htwah_SolsA {
source: include_bytes!("../../assets/htwah/sols-a.pdf"),
target: "/htwah/sols-a.pdf",
headers: [
(header::CONTENT_TYPE, MimeType::Pdf.to_string())
]
}
Htwah_SolsB {
source: include_bytes!("../../assets/htwah/sols-b.pdf"),
target: "/htwah/sols-b.pdf",
headers: [
(header::CONTENT_TYPE, MimeType::Pdf.to_string())
]
}
Htwah_SpacingA {
source: include_bytes!("../../assets/htwah/spacing-a.pdf"),
target: "/htwah/spacing-a.pdf",
headers: [
(header::CONTENT_TYPE, MimeType::Pdf.to_string())
]
}
Htwah_SpacingB {
source: include_bytes!("../../assets/htwah/spacing-b.pdf"),
target: "/htwah/spacing-b.pdf",
headers: [
(header::CONTENT_TYPE, MimeType::Pdf.to_string())
]
}
}

View File

@@ -1,34 +1,203 @@
use axum::Router; use axum::Router;
use page::{PageServer, redirect::Redirect}; use macro_sass::sass;
use std::sync::Arc; use page::{
use tracing::info; ServableRoute,
servable::{Redirect, StaticAsset},
};
use toolbox::mime::MimeType;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use crate::pages; use crate::pages;
pub mod assets;
pub(super) fn router() -> Router<()> { pub(super) fn router() -> Router<()> {
let (asset_prefix, asset_router) = assets::asset_router(); let compression: CompressionLayer = CompressionLayer::new()
info!("Serving assets at {asset_prefix}"); .br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
let router = build_server().into_router(); build_server().into_router().layer(compression)
Router::new().merge(router).nest(asset_prefix, asset_router)
} }
fn build_server() -> Arc<PageServer> { fn build_server() -> ServableRoute {
let server = PageServer::new(); ServableRoute::new()
.with_404(pages::notfound())
#[expect(clippy::unwrap_used)]
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())
.add_page("/htwah", Redirect::new("/handouts").unwrap()) .add_page("/htwah", {
.add_page("/htwah/typesetting", pages::htwah_typesetting()); #[expect(clippy::unwrap_used)]
Redirect::new("/handouts").unwrap()
server })
.add_page("/htwah/typesetting", pages::htwah_typesetting())
.add_page("/assets/htmx.js", page::HTMX_2_0_8)
.add_page("/assets/htmx-json.js", page::EXT_JSON_1_19_12)
//
.add_page(
"/assets/css/main.css",
StaticAsset {
bytes: sass!("css/main.scss").as_bytes(),
mime: MimeType::Css,
},
)
.add_page(
"/assets/img/cover-small.jpg",
StaticAsset {
bytes: include_bytes!("../../assets/images/cover-small.jpg"),
mime: MimeType::Jpg,
},
)
.add_page(
"/assets/img/betalupi.png",
StaticAsset {
bytes: include_bytes!("../../assets/images/betalupi-map.png"),
mime: MimeType::Png,
},
)
.add_page(
"/assets/img/icon.png",
StaticAsset {
bytes: include_bytes!("../../assets/images/icon.png"),
mime: MimeType::Png,
},
)
//
// MARK: fonts
//
.add_page(
"/assets/fonts/FiraCode-Bold.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Bold.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-Light.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Light.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-Medium.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Medium.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-Regular.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Regular.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-SemiBold.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-SemiBold.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-VF.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-VF.woff2"),
mime: MimeType::Woff2,
},
)
//
// MARK: icons
//
.add_page(
"/assets/fonts/fa/fa-brands-400.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/fa/fa-regular-400.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/fa/fa-solid-900.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/fa/fa-brands-400.ttf",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.ttf"),
mime: MimeType::Ttf,
},
)
.add_page(
"/assets/fonts/fa/fa-regular-400.ttf",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.ttf"),
mime: MimeType::Ttf,
},
)
.add_page(
"/assets/fonts/fa/fa-solid-900.ttf",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.ttf"),
mime: MimeType::Ttf,
},
)
//
// MARK: htwah
//
.add_page(
"/assets/htwah/definitions.pdf",
StaticAsset {
bytes: include_bytes!("../../assets/htwah/definitions.pdf"),
mime: MimeType::Pdf,
},
)
.add_page(
"/assets/htwah/numbering.pdf",
StaticAsset {
bytes: include_bytes!("../../assets/htwah/numbering.pdf"),
mime: MimeType::Pdf,
},
)
.add_page(
"/assets/htwah/sols-a.pdf",
StaticAsset {
bytes: include_bytes!("../../assets/htwah/sols-a.pdf"),
mime: MimeType::Pdf,
},
)
.add_page(
"/assets/htwah/sols-b.pdf",
StaticAsset {
bytes: include_bytes!("../../assets/htwah/sols-b.pdf"),
mime: MimeType::Pdf,
},
)
.add_page(
"/assets/htwah/spacing-a.pdf",
StaticAsset {
bytes: include_bytes!("../../assets/htwah/spacing-a.pdf"),
mime: MimeType::Pdf,
},
)
.add_page(
"/assets/htwah/spacing-b.pdf",
StaticAsset {
bytes: include_bytes!("../../assets/htwah/spacing-b.pdf"),
mime: MimeType::Pdf,
},
)
} }
#[test] #[test]
@@ -38,7 +207,6 @@ fn server_builds_without_panic() {
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
// Needs tokio context let _server = build_server();
let _server = build_server().into_router();
}); });
} }

View File

@@ -6,4 +6,4 @@ extend-ignore-re = [
] ]
[files] [files]
extend-exclude = ["crates/service/service-webpage/css"] extend-exclude = ["crates/service/service-webpage/css", "crates/lib/page/htmx"]