Compare commits

..

1 Commits

Author SHA1 Message Date
837dca4371 README
Some checks failed
CI / Check typos (push) Failing after 8s
CI / Check links (push) Failing after 11s
CI / Clippy (push) Successful in 57s
CI / Build and test (push) Successful in 1m19s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
2025-11-12 14:01:14 -08:00
60 changed files with 3123 additions and 932 deletions

597
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/bin/*", "crates/lib/*", "crates/service/*"] members = ["crates/bin/*", "crates/lib/*", "crates/macro/*", "crates/service/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
@@ -40,7 +40,7 @@ mutex_atomic = "deny"
needless_raw_strings = "deny" needless_raw_strings = "deny"
str_to_string = "deny" str_to_string = "deny"
string_add = "deny" string_add = "deny"
implicit_clone = "deny" string_to_string = "deny"
use_debug = "allow" use_debug = "allow"
verbose_file_reads = "deny" verbose_file_reads = "deny"
large_types_passed_by_value = "deny" large_types_passed_by_value = "deny"
@@ -64,20 +64,22 @@ type_complexity = "allow"
# #
[workspace.dependencies] [workspace.dependencies]
macro-sass = { path = "crates/macro/macro-sass" }
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" }
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" }
# #
# MARK: Server # MARK: Server
# #
axum = { version = "0.8.6", features = ["macros", "multipart"] } axum = { version = "0.8.6", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace", "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",
@@ -85,13 +87,12 @@ utoipa-swagger-ui = { version = "9.0.2", features = [
"vendored", "vendored",
] } ] }
maud = { version = "0.27.0", features = ["axum"] } maud = { version = "0.27.0", features = ["axum"] }
grass = { version = "0.13.4", features = ["macro"] } grass = "0.13.4"
markdown-it = "0.6.1" markdown-it = "0.6.1"
emojis = "0.8.0" emojis = "0.8.0"
reqwest = { version = "0.12.24", default-features = false, features = [ 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",
@@ -99,11 +100,6 @@ reqwest = { version = "0.12.24", default-features = false, features = [
"charset", "charset",
"blocking", "blocking",
] } ] }
servable = { version = "0.0.3", features = ["image", "htmx-2.0.8"] }
#servable = { path = "../servable/crates/servable", features = [
# "image",
# "htmx-2.0.8",
#] }
# #
@@ -148,9 +144,6 @@ 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" 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"

View File

@@ -18,33 +18,6 @@ This library decouples compiled binaries from the services they provide, and mak
- [`lib/page`](./crates/lib/page): Provides `PageServer`, which builds an [axum] router that provides a caching and headers for resources served through http. - [`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. - Also provides `Servable`, which is a trait for any resource that may be served.
- the `Page` servable serves html generated by a closure. - 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)). - the `StaticAsset` servable serves static assets (css, images, misc files), and provides transformation utilties for image assets (via [`pixel-transform`](./crates/lib/pixel-transform)).
- [`service/service-webpage`](./crates/service/service-webpage): A `Service` that runs a `PageServer` that provides the content on [betalupi.com] - [`service/service-webpage`](./crates/service/service-webpage): A `Service` that runs a `PageServer` that provides the content on [betalupi.com]
## Todo:
This web framework is nowhere near complete. Features are added as they are needed.
### Asset server
- generate asset server from dir, detect mime from file
- icon svg
- CORS,timeout, page cache
### Misc:
- reactive components with react or htmx
- handout search
- self-contained email mangler
- check asset paths at compile-time (or at least in a test)
### Content:
- TetrOS:
- https://git.betalupi.com/Mark/tetros
- https://git.betalupi.com/Mark/tetris-os
- Pick:
- https://git.betalupi.com/Mark/pick
- Minimax
- HTWAH

View File

@@ -0,0 +1,23 @@
[package]
name = "page"
version = { workspace = true }
rust-version = { workspace = true }
edition = { workspace = true }
[lints]
workspace = true
[dependencies]
toolbox = { workspace = true }
libservice = { workspace = true }
pixel-transform = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
maud = { workspace = true }
chrono = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true }
lru = { workspace = true }
tower-http = { workspace = true }

View File

@@ -0,0 +1,8 @@
mod servable;
pub use servable::*;
mod requestcontext;
pub use requestcontext::*;
mod server;
pub use server::*;

View File

@@ -0,0 +1,64 @@
use std::collections::BTreeMap;
use axum::http::HeaderMap;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RequestContext {
pub client_info: ClientInfo,
pub route: String,
pub query: BTreeMap<String, String>,
}
//
//
//
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DeviceType {
Mobile,
Desktop,
}
impl Default for DeviceType {
fn default() -> Self {
Self::Desktop
}
}
//
// MARK: clientinfo
//
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ClientInfo {
/// This is an estimate, but it's probably good enough.
pub device_type: DeviceType,
}
impl ClientInfo {
pub fn from_headers(headers: &HeaderMap) -> Self {
let ua = headers
.get("user-agent")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
let ch_mobile = headers
.get("Sec-CH-UA-Mobile")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
let mut device_type = None;
if device_type.is_none() && ch_mobile.contains("1") {
device_type = Some(DeviceType::Mobile);
}
if device_type.is_none() && ua.contains("Mobile") {
device_type = Some(DeviceType::Mobile);
}
Self {
device_type: device_type.unwrap_or_default(),
}
}
}

View File

@@ -0,0 +1,116 @@
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::{Rendered, RenderedBody, RequestContext, Servable};
pub struct StaticAsset {
pub bytes: &'static [u8],
pub mime: MimeType,
}
impl Servable for StaticAsset {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + '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

@@ -0,0 +1,3 @@
pub mod asset;
pub mod page;
pub mod redirect;

View File

@@ -0,0 +1,127 @@
use axum::http::{HeaderMap, StatusCode};
use chrono::TimeDelta;
use maud::{Markup, Render, html};
use serde::Deserialize;
use std::pin::Pin;
use toolbox::mime::MimeType;
use crate::{Rendered, RenderedBody, RequestContext, Servable};
//
// MARK: metadata
//
#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize)]
pub struct PageMetadata {
pub title: String,
pub author: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
pub backlinks: Option<bool>,
}
impl Default for PageMetadata {
fn default() -> Self {
Self {
title: "Untitled page".into(),
author: None,
description: 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
//
// Some HTML
pub struct Page {
pub meta: PageMetadata,
pub immutable: bool,
/// How long this page's html may be cached.
/// This controls the maximum age of a page shown to the user.
///
/// If `None`, this page is always rendered from scratch.
pub html_ttl: Option<TimeDelta>,
/// A function that generates this page's html.
///
/// This should return the contents of this page's <body> tag,
/// or the contents of a wrapper element (defined in the page server struct).
///
/// This closure must never return `<html>` or `<head>`.
pub generate_html: Box<
dyn Send
+ Sync
+ for<'a> Fn(
&'a Page,
&'a RequestContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
>,
}
impl Default for Page {
fn default() -> Self {
Page {
meta: Default::default(),
html_ttl: Some(TimeDelta::days(1)),
generate_html: Box::new(|_, _| Box::pin(async { html!() })),
immutable: true,
}
}
}
impl Page {
pub async fn generate_html(&self, ctx: &RequestContext) -> Markup {
(self.generate_html)(self, ctx).await
}
}
impl Servable for Page {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
Box::pin(async {
let html = self.generate_html(ctx).await;
return Rendered {
code: StatusCode::OK,
body: RenderedBody::Markup(html),
ttl: self.html_ttl,
immutable: self.immutable,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
};
})
}
}

View File

@@ -0,0 +1,41 @@
use std::pin::Pin;
use axum::http::{
HeaderMap, HeaderValue, StatusCode,
header::{self, InvalidHeaderValue},
};
use crate::{Rendered, RenderedBody, RequestContext, Servable};
pub struct Redirect {
to: HeaderValue,
}
impl Redirect {
pub fn new(to: impl Into<String>) -> Result<Self, InvalidHeaderValue> {
Ok(Self {
to: HeaderValue::from_str(&to.into())?,
})
}
}
impl Servable for Redirect {
fn render<'a>(
&'a self,
_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
Box::pin(async {
let mut headers = HeaderMap::with_capacity(1);
headers.append(header::LOCATION, self.to.clone());
return Rendered {
code: StatusCode::PERMANENT_REDIRECT,
headers,
body: RenderedBody::Empty,
ttl: None,
immutable: true,
mime: None,
};
})
}
}

View File

@@ -0,0 +1,324 @@
use axum::{
Router,
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use chrono::{DateTime, TimeDelta, Utc};
use libservice::ServiceConnectInfo;
use lru::LruCache;
use maud::Markup;
use parking_lot::Mutex;
use std::{
collections::{BTreeMap, HashMap},
num::NonZero,
pin::Pin,
sync::Arc,
time::Instant,
};
use toolbox::mime::MimeType;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::trace;
use crate::{ClientInfo, RequestContext};
#[derive(Clone)]
pub enum RenderedBody {
Markup(Markup),
Static(&'static [u8]),
Bytes(Vec<u8>),
String(String),
Empty,
}
#[derive(Clone)]
pub struct Rendered {
pub code: StatusCode,
pub headers: HeaderMap,
pub body: RenderedBody,
pub mime: Option<MimeType>,
/// How long to cache this response.
/// If none, don't cache.
pub ttl: Option<TimeDelta>,
pub immutable: bool,
}
pub trait Servable: Send + Sync {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
}
pub struct Default404 {}
impl Servable for Default404 {
fn render<'a>(
&'a self,
_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
Box::pin(async {
return Rendered {
code: StatusCode::NOT_FOUND,
body: RenderedBody::String("page not found".into()),
ttl: Some(TimeDelta::days(1)),
immutable: true,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
};
})
}
}
//
// MARK: server
//
pub struct PageServer {
/// 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: Mutex<HashMap<String, Arc<dyn Servable>>>,
notfound: Mutex<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: Mutex::new(HashMap::new()),
page_cache: Mutex::new(LruCache::new(cache_size)),
never_rerender_on_request: true,
notfound: Mutex::new(Arc::new(Default404 {})),
})
}
/// Set this server's "not found" page
pub fn with_404<S: Servable + 'static>(&self, page: S) -> &Self {
*self.notfound.lock() = Arc::new(page);
self
}
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
#[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,
) -> (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 => self.notfound.lock().clone(),
};
trace!(
message = "Rendering page",
route = route.to_owned(),
reason,
lock_time_ms = start.elapsed().as_millis()
);
let rendered = page.render(&ctx).await;
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 = route.to_owned(),
reason,
time_ms = elapsed
);
return (rendered, expires);
}
async fn handler(
Path(route): Path<String>,
Query(query): Query<BTreeMap<String, 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}"),
query,
};
let now = Utc::now();
let mut html_expires = None;
let mut cached = true;
// 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() {
cached = false;
html_expires = Some(state.render_page("request", &route, ctx).await);
}
#[expect(clippy::unwrap_used)]
let (mut html, expires) = html_expires.unwrap();
if !html.headers.contains_key(header::CACHE_CONTROL) {
let max_age = match expires {
Some(expires) => (expires - now).num_seconds().max(1),
None => 1,
};
let mut value = String::new();
if html.immutable {
value.push_str("immutable, ");
}
value.push_str("public, ");
value.push_str(&format!("max-age={}, ", max_age));
#[expect(clippy::unwrap_used)]
html.headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
);
}
if !html.headers.contains_key("Accept-CH") {
html.headers
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
}
if let Some(mime) = &html.mime {
#[expect(clippy::unwrap_used)]
html.headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&mime.to_string()).unwrap(),
);
}
trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos()
);
return match html.body {
RenderedBody::Markup(markup) => (html.code, html.headers, markup.0).into_response(),
RenderedBody::Static(data) => (html.code, html.headers, data).into_response(),
RenderedBody::Bytes(data) => (html.code, html.headers, data).into_response(),
RenderedBody::String(s) => (html.code, html.headers, s).into_response(),
RenderedBody::Empty => (html.code, html.headers).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, query, conn, headers| async {
Self::handler(Path(String::new()), query, state, conn, headers).await
}),
)
.route("/{*path}", get(Self::handler))
.layer(compression)
.with_state(self)
}
}

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,145 @@
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 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

@@ -12,6 +12,7 @@ tokio = { workspace = true }
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
tracing = { workspace = true }
num = { workspace = true } num = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
envy = { workspace = true } envy = { workspace = true }

View File

@@ -1,6 +1,8 @@
//! This crate contains various bits of useful code that don't fit anywhere else. //! This crate contains various bits of useful code that don't fit anywhere else.
pub mod env; pub mod env;
pub mod mime;
pub mod misc;
pub mod strings; pub mod strings;
#[cfg(feature = "cli")] #[cfg(feature = "cli")]

View File

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

View File

@@ -0,0 +1,690 @@
use std::{fmt::Display, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use tracing::debug;
/// A media type, conveniently parsed
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum MimeType {
/// A mimetype we didn't recognize
Other(String),
/// An unstructured binary blob
/// Use this whenever a mime type is unknown
Blob,
// MARK: Audio
/// AAC audio file (audio/aac)
Aac,
/// FLAC audio file (audio/flac)
Flac,
/// MIDI audio file (audio/midi)
Midi,
/// MP3 audio file (audio/mpeg)
Mp3,
/// OGG audio file (audio/ogg)
Oga,
/// Opus audio file in Ogg container (audio/ogg)
Opus,
/// Waveform Audio Format (audio/wav)
Wav,
/// WEBM audio file (audio/webm)
Weba,
// MARK: Video
/// AVI: Audio Video Interleave (video/x-msvideo)
Avi,
/// MP4 video file (video/mp4)
Mp4,
/// MPEG video file (video/mpeg)
Mpeg,
/// OGG video file (video/ogg)
Ogv,
/// MPEG transport stream (video/mp2t)
Ts,
/// WEBM video file (video/webm)
WebmVideo,
/// 3GPP audio/video container (video/3gpp)
ThreeGp,
/// 3GPP2 audio/video container (video/3gpp2)
ThreeG2,
// MARK: Images
/// Animated Portable Network Graphics (image/apng)
Apng,
/// AVIF image (image/avif)
Avif,
/// Windows OS/2 Bitmap Graphics (image/bmp)
Bmp,
/// Graphics Interchange Format (image/gif)
Gif,
/// Icon format (image/vnd.microsoft.icon)
Ico,
/// JPEG image (image/jpeg)
Jpg,
/// Portable Network Graphics (image/png)
Png,
/// Quite ok Image Format
Qoi,
/// Scalable Vector Graphics (image/svg+xml)
Svg,
/// Tagged Image File Format (image/tiff)
Tiff,
/// WEBP image (image/webp)
Webp,
// MARK: Text
/// Plain text (text/plain)
Text,
/// Cascading Style Sheets (text/css)
Css,
/// Comma-separated values (text/csv)
Csv,
/// HyperText Markup Language (text/html)
Html,
/// JavaScript (text/javascript)
Javascript,
/// JSON format (application/json)
Json,
/// JSON-LD format (application/ld+json)
JsonLd,
/// XML (application/xml)
Xml,
// MARK: Documents
/// Adobe Portable Document Format (application/pdf)
Pdf,
/// Rich Text Format (application/rtf)
Rtf,
// MARK: Archives
/// Archive document, multiple files embedded (application/x-freearc)
Arc,
/// BZip archive (application/x-bzip)
Bz,
/// BZip2 archive (application/x-bzip2)
Bz2,
/// GZip Compressed Archive (application/gzip)
Gz,
/// Java Archive (application/java-archive)
Jar,
/// OGG (application/ogg)
Ogg,
/// RAR archive (application/vnd.rar)
Rar,
/// 7-zip archive (application/x-7z-compressed)
SevenZ,
/// Tape Archive (application/x-tar)
Tar,
/// ZIP archive (application/zip)
Zip,
// MARK: Fonts
/// MS Embedded OpenType fonts (application/vnd.ms-fontobject)
Eot,
/// OpenType font (font/otf)
Otf,
/// TrueType Font (font/ttf)
Ttf,
/// Web Open Font Format (font/woff)
Woff,
/// Web Open Font Format 2 (font/woff2)
Woff2,
// MARK: Applications
/// AbiWord document (application/x-abiword)
Abiword,
/// Amazon Kindle eBook format (application/vnd.amazon.ebook)
Azw,
/// CD audio (application/x-cdf)
Cda,
/// C-Shell script (application/x-csh)
Csh,
/// Microsoft Word (application/msword)
Doc,
/// Microsoft Word OpenXML (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
Docx,
/// Electronic publication (application/epub+zip)
Epub,
/// iCalendar format (text/calendar)
Ics,
/// Apple Installer Package (application/vnd.apple.installer+xml)
Mpkg,
/// OpenDocument presentation (application/vnd.oasis.opendocument.presentation)
Odp,
/// OpenDocument spreadsheet (application/vnd.oasis.opendocument.spreadsheet)
Ods,
/// OpenDocument text document (application/vnd.oasis.opendocument.text)
Odt,
/// Hypertext Preprocessor (application/x-httpd-php)
Php,
/// Microsoft PowerPoint (application/vnd.ms-powerpoint)
Ppt,
/// Microsoft PowerPoint OpenXML (application/vnd.openxmlformats-officedocument.presentationml.presentation)
Pptx,
/// Bourne shell script (application/x-sh)
Sh,
/// Microsoft Visio (application/vnd.visio)
Vsd,
/// XHTML (application/xhtml+xml)
Xhtml,
/// Microsoft Excel (application/vnd.ms-excel)
Xls,
/// Microsoft Excel OpenXML (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet)
Xlsx,
/// XUL (application/vnd.mozilla.xul+xml)
Xul,
}
// MARK: ser/de
/*
impl utoipa::ToSchema for MimeType {
fn name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("MimeType")
}
}
impl utoipa::PartialSchema for MimeType {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::Schema::Object(
utoipa::openapi::schema::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::SchemaType::Type(Type::String))
.description(Some(
"A media type string (e.g., 'application/json', 'text/plain')",
))
.examples(Some("application/json"))
.build(),
)
.into()
}
}
*/
impl Serialize for MimeType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for MimeType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(MimeType::from_str(&s).unwrap())
}
}
//
// MARK: misc
//
impl Default for MimeType {
fn default() -> Self {
Self::Blob
}
}
impl From<String> for MimeType {
fn from(value: String) -> Self {
Self::from_str(&value).unwrap()
}
}
impl From<&str> for MimeType {
fn from(value: &str) -> Self {
Self::from_str(value).unwrap()
}
}
impl From<&MimeType> for String {
fn from(value: &MimeType) -> Self {
value.to_string()
}
}
//
// MARK: fromstr
//
impl FromStr for MimeType {
type Err = std::convert::Infallible;
// Must match `display` below, but may provide other alternatives.
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"application/octet-stream" => Self::Blob,
// Audio
"audio/aac" => Self::Aac,
"audio/flac" => Self::Flac,
"audio/midi" | "audio/x-midi" => Self::Midi,
"audio/mpeg" => Self::Mp3,
"audio/ogg" => Self::Oga,
"audio/wav" => Self::Wav,
"audio/webm" => Self::Weba,
// Video
"video/x-msvideo" => Self::Avi,
"video/mp4" => Self::Mp4,
"video/mpeg" => Self::Mpeg,
"video/ogg" => Self::Ogv,
"video/mp2t" => Self::Ts,
"video/webm" => Self::WebmVideo,
"video/3gpp" => Self::ThreeGp,
"video/3gpp2" => Self::ThreeG2,
// Images
"image/apng" => Self::Apng,
"image/avif" => Self::Avif,
"image/bmp" => Self::Bmp,
"image/gif" => Self::Gif,
"image/vnd.microsoft.icon" => Self::Ico,
"image/jpeg" | "image/jpg" => Self::Jpg,
"image/png" => Self::Png,
"image/svg+xml" => Self::Svg,
"image/tiff" => Self::Tiff,
"image/webp" => Self::Webp,
"image/qoi" => Self::Qoi,
// Text
"text/plain" => Self::Text,
"text/css" => Self::Css,
"text/csv" => Self::Csv,
"text/html" => Self::Html,
"text/javascript" => Self::Javascript,
"application/json" => Self::Json,
"application/ld+json" => Self::JsonLd,
"application/xml" | "text/xml" => Self::Xml,
// Documents
"application/pdf" => Self::Pdf,
"application/rtf" => Self::Rtf,
// Archives
"application/x-freearc" => Self::Arc,
"application/x-bzip" => Self::Bz,
"application/x-bzip2" => Self::Bz2,
"application/gzip" | "application/x-gzip" => Self::Gz,
"application/java-archive" => Self::Jar,
"application/ogg" => Self::Ogg,
"application/vnd.rar" => Self::Rar,
"application/x-7z-compressed" => Self::SevenZ,
"application/x-tar" => Self::Tar,
"application/zip" | "application/x-zip-compressed" => Self::Zip,
// Fonts
"application/vnd.ms-fontobject" => Self::Eot,
"font/otf" => Self::Otf,
"font/ttf" => Self::Ttf,
"font/woff" => Self::Woff,
"font/woff2" => Self::Woff2,
// Applications
"application/x-abiword" => Self::Abiword,
"application/vnd.amazon.ebook" => Self::Azw,
"application/x-cdf" => Self::Cda,
"application/x-csh" => Self::Csh,
"application/msword" => Self::Doc,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => Self::Docx,
"application/epub+zip" => Self::Epub,
"text/calendar" => Self::Ics,
"application/vnd.apple.installer+xml" => Self::Mpkg,
"application/vnd.oasis.opendocument.presentation" => Self::Odp,
"application/vnd.oasis.opendocument.spreadsheet" => Self::Ods,
"application/vnd.oasis.opendocument.text" => Self::Odt,
"application/x-httpd-php" => Self::Php,
"application/vnd.ms-powerpoint" => Self::Ppt,
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => {
Self::Pptx
}
"application/x-sh" => Self::Sh,
"application/vnd.visio" => Self::Vsd,
"application/xhtml+xml" => Self::Xhtml,
"application/vnd.ms-excel" => Self::Xls,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Self::Xlsx,
"application/vnd.mozilla.xul+xml" => Self::Xul,
_ => {
debug!(message = "Encountered unknown mimetype", mime_string = s);
Self::Other(s.into())
}
})
}
}
//
// MARK: display
//
impl Display for MimeType {
/// Get a string representation of this mimetype.
///
/// The following always holds:
/// ```rust
/// # use toolbox::mime::MimeType;
/// # let x = MimeType::Blob;
/// assert_eq!(MimeType::from(x.to_string()), x);
/// ```
///
/// The following might not hold:
/// ```rust
/// # use toolbox::mime::MimeType;
/// # let y = "application/custom";
/// // MimeType::from(y).to_string() may not equal y
/// ```
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Blob => write!(f, "application/octet-stream"),
// Audio
Self::Aac => write!(f, "audio/aac"),
Self::Flac => write!(f, "audio/flac"),
Self::Midi => write!(f, "audio/midi"),
Self::Mp3 => write!(f, "audio/mpeg"),
Self::Oga => write!(f, "audio/ogg"),
Self::Opus => write!(f, "audio/ogg"),
Self::Wav => write!(f, "audio/wav"),
Self::Weba => write!(f, "audio/webm"),
// Video
Self::Avi => write!(f, "video/x-msvideo"),
Self::Mp4 => write!(f, "video/mp4"),
Self::Mpeg => write!(f, "video/mpeg"),
Self::Ogv => write!(f, "video/ogg"),
Self::Ts => write!(f, "video/mp2t"),
Self::WebmVideo => write!(f, "video/webm"),
Self::ThreeGp => write!(f, "video/3gpp"),
Self::ThreeG2 => write!(f, "video/3gpp2"),
// Images
Self::Apng => write!(f, "image/apng"),
Self::Avif => write!(f, "image/avif"),
Self::Bmp => write!(f, "image/bmp"),
Self::Gif => write!(f, "image/gif"),
Self::Ico => write!(f, "image/vnd.microsoft.icon"),
Self::Jpg => write!(f, "image/jpeg"),
Self::Png => write!(f, "image/png"),
Self::Svg => write!(f, "image/svg+xml"),
Self::Tiff => write!(f, "image/tiff"),
Self::Webp => write!(f, "image/webp"),
Self::Qoi => write!(f, "image/qoi"),
// Text
Self::Text => write!(f, "text/plain"),
Self::Css => write!(f, "text/css"),
Self::Csv => write!(f, "text/csv"),
Self::Html => write!(f, "text/html"),
Self::Javascript => write!(f, "text/javascript"),
Self::Json => write!(f, "application/json"),
Self::JsonLd => write!(f, "application/ld+json"),
Self::Xml => write!(f, "application/xml"),
// Documents
Self::Pdf => write!(f, "application/pdf"),
Self::Rtf => write!(f, "application/rtf"),
// Archives
Self::Arc => write!(f, "application/x-freearc"),
Self::Bz => write!(f, "application/x-bzip"),
Self::Bz2 => write!(f, "application/x-bzip2"),
Self::Gz => write!(f, "application/gzip"),
Self::Jar => write!(f, "application/java-archive"),
Self::Ogg => write!(f, "application/ogg"),
Self::Rar => write!(f, "application/vnd.rar"),
Self::SevenZ => write!(f, "application/x-7z-compressed"),
Self::Tar => write!(f, "application/x-tar"),
Self::Zip => write!(f, "application/zip"),
// Fonts
Self::Eot => write!(f, "application/vnd.ms-fontobject"),
Self::Otf => write!(f, "font/otf"),
Self::Ttf => write!(f, "font/ttf"),
Self::Woff => write!(f, "font/woff"),
Self::Woff2 => write!(f, "font/woff2"),
// Applications
Self::Abiword => write!(f, "application/x-abiword"),
Self::Azw => write!(f, "application/vnd.amazon.ebook"),
Self::Cda => write!(f, "application/x-cdf"),
Self::Csh => write!(f, "application/x-csh"),
Self::Doc => write!(f, "application/msword"),
Self::Docx => write!(
f,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
),
Self::Epub => write!(f, "application/epub+zip"),
Self::Ics => write!(f, "text/calendar"),
Self::Mpkg => write!(f, "application/vnd.apple.installer+xml"),
Self::Odp => write!(f, "application/vnd.oasis.opendocument.presentation"),
Self::Ods => write!(f, "application/vnd.oasis.opendocument.spreadsheet"),
Self::Odt => write!(f, "application/vnd.oasis.opendocument.text"),
Self::Php => write!(f, "application/x-httpd-php"),
Self::Ppt => write!(f, "application/vnd.ms-powerpoint"),
Self::Pptx => write!(
f,
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
),
Self::Sh => write!(f, "application/x-sh"),
Self::Vsd => write!(f, "application/vnd.visio"),
Self::Xhtml => write!(f, "application/xhtml+xml"),
Self::Xls => write!(f, "application/vnd.ms-excel"),
Self::Xlsx => write!(
f,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
),
Self::Xul => write!(f, "application/vnd.mozilla.xul+xml"),
Self::Other(x) => write!(f, "{x}"),
}
}
}
impl MimeType {
//
// MARK: from extension
//
/// Try to guess a file's mime type from its extension.
/// `ext` should NOT start with a dot.
pub fn from_extension(ext: &str) -> Option<Self> {
Some(match ext {
// Audio
"aac" => Self::Aac,
"flac" => Self::Flac,
"mid" | "midi" => Self::Midi,
"mp3" => Self::Mp3,
"oga" => Self::Oga,
"opus" => Self::Opus,
"wav" => Self::Wav,
"weba" => Self::Weba,
// Video
"avi" => Self::Avi,
"mp4" => Self::Mp4,
"mpeg" => Self::Mpeg,
"ogv" => Self::Ogv,
"ts" => Self::Ts,
"webm" => Self::WebmVideo,
"3gp" => Self::ThreeGp,
"3g2" => Self::ThreeG2,
// Images
"apng" => Self::Apng,
"avif" => Self::Avif,
"bmp" => Self::Bmp,
"gif" => Self::Gif,
"ico" => Self::Ico,
"jpg" | "jpeg" => Self::Jpg,
"png" => Self::Png,
"svg" => Self::Svg,
"tif" | "tiff" => Self::Tiff,
"webp" => Self::Webp,
"qoi" => Self::Qoi,
// Text
"txt" => Self::Text,
"css" => Self::Css,
"csv" => Self::Csv,
"htm" | "html" => Self::Html,
"js" | "mjs" => Self::Javascript,
"json" => Self::Json,
"jsonld" => Self::JsonLd,
"xml" => Self::Xml,
// Documents
"pdf" => Self::Pdf,
"rtf" => Self::Rtf,
// Archives
"arc" => Self::Arc,
"bz" => Self::Bz,
"bz2" => Self::Bz2,
"gz" => Self::Gz,
"jar" => Self::Jar,
"ogx" => Self::Ogg,
"rar" => Self::Rar,
"7z" => Self::SevenZ,
"tar" => Self::Tar,
"zip" => Self::Zip,
// Fonts
"eot" => Self::Eot,
"otf" => Self::Otf,
"ttf" => Self::Ttf,
"woff" => Self::Woff,
"woff2" => Self::Woff2,
// Applications
"abw" => Self::Abiword,
"azw" => Self::Azw,
"cda" => Self::Cda,
"csh" => Self::Csh,
"doc" => Self::Doc,
"docx" => Self::Docx,
"epub" => Self::Epub,
"ics" => Self::Ics,
"mpkg" => Self::Mpkg,
"odp" => Self::Odp,
"ods" => Self::Ods,
"odt" => Self::Odt,
"php" => Self::Php,
"ppt" => Self::Ppt,
"pptx" => Self::Pptx,
"sh" => Self::Sh,
"vsd" => Self::Vsd,
"xhtml" => Self::Xhtml,
"xls" => Self::Xls,
"xlsx" => Self::Xlsx,
"xul" => Self::Xul,
_ => return None,
})
}
//
// MARK: to extension
//
/// Get the extension we use for files with this type.
/// Never includes a dot.
pub fn extension(&self) -> Option<&'static str> {
match self {
Self::Blob => None,
Self::Other(_) => None,
// Audio
Self::Aac => Some("aac"),
Self::Flac => Some("flac"),
Self::Midi => Some("midi"),
Self::Mp3 => Some("mp3"),
Self::Oga => Some("oga"),
Self::Opus => Some("opus"),
Self::Wav => Some("wav"),
Self::Weba => Some("weba"),
// Video
Self::Avi => Some("avi"),
Self::Mp4 => Some("mp4"),
Self::Mpeg => Some("mpeg"),
Self::Ogv => Some("ogv"),
Self::Ts => Some("ts"),
Self::WebmVideo => Some("webm"),
Self::ThreeGp => Some("3gp"),
Self::ThreeG2 => Some("3g2"),
// Images
Self::Apng => Some("apng"),
Self::Avif => Some("avif"),
Self::Bmp => Some("bmp"),
Self::Gif => Some("gif"),
Self::Ico => Some("ico"),
Self::Jpg => Some("jpg"),
Self::Png => Some("png"),
Self::Svg => Some("svg"),
Self::Tiff => Some("tiff"),
Self::Webp => Some("webp"),
Self::Qoi => Some("qoi"),
// Text
Self::Text => Some("txt"),
Self::Css => Some("css"),
Self::Csv => Some("csv"),
Self::Html => Some("html"),
Self::Javascript => Some("js"),
Self::Json => Some("json"),
Self::JsonLd => Some("jsonld"),
Self::Xml => Some("xml"),
// Documents
Self::Pdf => Some("pdf"),
Self::Rtf => Some("rtf"),
// Archives
Self::Arc => Some("arc"),
Self::Bz => Some("bz"),
Self::Bz2 => Some("bz2"),
Self::Gz => Some("gz"),
Self::Jar => Some("jar"),
Self::Ogg => Some("ogx"),
Self::Rar => Some("rar"),
Self::SevenZ => Some("7z"),
Self::Tar => Some("tar"),
Self::Zip => Some("zip"),
// Fonts
Self::Eot => Some("eot"),
Self::Otf => Some("otf"),
Self::Ttf => Some("ttf"),
Self::Woff => Some("woff"),
Self::Woff2 => Some("woff2"),
// Applications
Self::Abiword => Some("abw"),
Self::Azw => Some("azw"),
Self::Cda => Some("cda"),
Self::Csh => Some("csh"),
Self::Doc => Some("doc"),
Self::Docx => Some("docx"),
Self::Epub => Some("epub"),
Self::Ics => Some("ics"),
Self::Mpkg => Some("mpkg"),
Self::Odp => Some("odp"),
Self::Ods => Some("ods"),
Self::Odt => Some("odt"),
Self::Php => Some("php"),
Self::Ppt => Some("ppt"),
Self::Pptx => Some("pptx"),
Self::Sh => Some("sh"),
Self::Vsd => Some("vsd"),
Self::Xhtml => Some("xhtml"),
Self::Xls => Some("xls"),
Self::Xlsx => Some("xlsx"),
Self::Xul => Some("xul"),
}
}
}

View File

@@ -0,0 +1,36 @@
/// Normalize a domain. This does the following:
/// - removes protocol prefixes
/// - removes leading `www`
/// - removes query params and path segments.
///
/// This function is for roach, and should exactly match the ts implementation.
///
/// ## Examples:
/// ```
/// # use toolbox::misc::normalize_domain;
/// assert_eq!("domain.com", normalize_domain("domain.com"));
/// assert_eq!("domain.com", normalize_domain("domain.com/"));
/// assert_eq!("domain.com", normalize_domain("domain.com/en/us"));
/// assert_eq!("domain.com", normalize_domain("domain.com/?key=val"));
/// assert_eq!("domain.com", normalize_domain("www.domain.com"));
/// assert_eq!("domain.com", normalize_domain("https://www.domain.com"));
/// assert_eq!("us.domain.com", normalize_domain("us.domain.com"));
/// ```
pub fn normalize_domain(domain: &str) -> &str {
let mut domain = domain.strip_prefix("http://").unwrap_or(domain);
domain = domain.strip_prefix("https://").unwrap_or(domain);
domain = domain.strip_prefix("www.").unwrap_or(domain);
domain = domain.find("/").map_or(domain, |x| &domain[0..x]);
return domain;
}
/*
pub fn random_string(length: usize) -> String {
rand::rng()
.sample_iter(&Alphanumeric)
.take(length)
.map(char::from)
.collect()
}
*/

View File

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

View File

@@ -0,0 +1,149 @@
use proc_macro::TokenStream;
use quote::quote;
use std::path::PathBuf;
use syn::{LitStr, parse_macro_input};
/// A macro for parsing Sass/SCSS files at compile time.
///
/// This macro takes a file path to a Sass/SCSS file, compiles it to CSS at compile time
/// using the `grass` compiler, and returns the resulting CSS as a `&'static str`.
///
/// # Behavior
///
/// Similar to `include_str!`, this macro:
/// - Reads the specified file at compile time
/// - Compiles the Sass/SCSS to CSS using `grass::from_path()` with default options
/// - Handles `@import` and `@use` directives, resolving imported files relative to the main file
/// - Embeds the resulting CSS string in the binary
/// - Returns a `&'static str` containing the compiled CSS
///
/// # Syntax
///
/// ```notrust
/// sass!("path/to/file.scss")
/// ```
///
/// # Arguments
///
/// - A string literal containing the path to the Sass/SCSS file (relative to the crate root)
///
/// # Example
///
/// ```notrust
/// // Relative to crate root: looks for src/routes/css/main.scss
/// const MY_STYLES: &str = sass!("src/routes/css/main.scss");
///
/// // Use in HTML generation
/// html! {
/// style { (PreEscaped(MY_STYLES)) }
/// }
/// ```
///
/// # Import Support
///
/// The macro fully supports Sass imports and uses:
/// ```notrust
/// // main.scss
/// @import "variables";
/// @use "mixins";
///
/// .button {
/// color: $primary-color;
/// }
/// ```
///
/// All imported files are resolved relative to the location of the main Sass file.
///
/// # Compile-time vs Runtime
///
/// Instead of this runtime code:
/// ```notrust
/// let css = grass::from_path(
/// "styles.scss",
/// &grass::Options::default()
/// ).unwrap();
/// ```
///
/// You can use:
/// ```notrust
/// const CSS: &str = sass!("styles.scss");
/// ```
///
/// # Panics
///
/// This macro will cause a compile error if:
/// - The specified file does not exist
/// - The file path is invalid
/// - The Sass/SCSS file contains syntax errors
/// - Any imported files cannot be found
/// - The grass compiler fails for any reason
///
/// # Note
///
/// The file path is relative to the crate root (where `Cargo.toml` is located), determined
/// by the `CARGO_MANIFEST_DIR` environment variable. This is similar to how `include!()` works
/// but differs from `include_str!()` which is relative to the current file.
#[proc_macro]
pub fn sass(input: TokenStream) -> TokenStream {
let input_lit = parse_macro_input!(input as LitStr);
let file_path = PathBuf::from(input_lit.value());
// Not stable yet, we have to use crate-relative paths :(
//let span = proc_macro::Span::call_site();
//let source_file = span.source_file();
//let path: PathBuf = source_file.path();
// Use a combination of include_str! and grass compilation
// include_str! handles the relative path resolution for us
// We generate code that uses include_str! at the user's call site
// and compiles it at macro expansion time
// First, try to read and compile the file at macro expansion time
// The path is interpreted relative to CARGO_MANIFEST_DIR
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_owned());
let full_path = std::path::Path::new(&manifest_dir).join(&file_path);
let css = match grass::from_path(&full_path, &grass::Options::default()) {
Ok(css) => css,
Err(e) => {
return syn::Error::new(
input_lit.span(),
format!(
"Failed to compile Sass file '{}': {}",
file_path.display(),
e,
),
)
.to_compile_error()
.into();
}
};
// Generate code that returns the compiled CSS as a string literal
let expanded = quote! {
#css
};
TokenStream::from(expanded)
}
#[proc_macro]
pub fn sass_str(input: TokenStream) -> TokenStream {
let input_lit = parse_macro_input!(input as LitStr);
let sass_str = input_lit.value();
let css = match grass::from_string(&sass_str, &grass::Options::default()) {
Ok(css) => css,
Err(e) => {
return syn::Error::new(
input_lit.span(),
format!("Failed to compile Sass string: {e}."),
)
.to_compile_error()
.into();
}
};
let expanded = quote! { #css };
TokenStream::from(expanded)
}

View File

@@ -9,11 +9,13 @@ workspace = true
[dependencies] [dependencies]
libservice = { workspace = true } libservice = { workspace = true }
macro-sass = { workspace = true }
toolbox = { workspace = true }
page = { workspace = true }
md-footnote = { workspace = true } md-footnote = { workspace = true }
markdown-it = { workspace = true } markdown-it = { workspace = true }
grass = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
maud = { workspace = true } maud = { workspace = true }
@@ -26,5 +28,3 @@ toml = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tower-http = { workspace = true }
servable = { workspace = true }

View File

@@ -45,7 +45,7 @@
--codeFgColor: var(--fgColor); --codeFgColor: var(--fgColor);
// Main colors // Main colors
--grey: #757575; --grey: #696969;
// Accent colors, used only manally // Accent colors, used only manally
--green: #a2c579; --green: #a2c579;
@@ -53,7 +53,6 @@
--orange: #e86a33; --orange: #e86a33;
--yellow: #e8bc00; --yellow: #e8bc00;
--pink: #fa9f83; --pink: #fa9f83;
--cyan: #6199bb;
} }
::selection, ::selection,

View File

@@ -1,8 +1,9 @@
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}; use maud::{Markup, PreEscaped, Render, html};
use servable::PageMetadata; use page::RequestContext;
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};
@@ -85,6 +86,10 @@ 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
@@ -96,3 +101,33 @@ 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

@@ -13,6 +13,8 @@ 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!
@@ -34,6 +36,7 @@ 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 style="margin:5rem 0 5rem 0;"></hr> <hr></hr>
<br></br> <br></br>

View File

@@ -1,23 +1,22 @@
use std::{ use std::{
pin::Pin, pin::Pin,
sync::{Arc, LazyLock}, sync::Arc,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
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 parking_lot::Mutex; use parking_lot::Mutex;
use serde::Deserialize; use serde::Deserialize;
use servable::{DeviceType, HtmlPage, RenderContext};
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::{ use crate::{
components::{ components::{
md::{Markdown, meta_from_markdown}, md::{Markdown, backlinks, meta_from_markdown},
misc::FarLink, misc::FarLink,
}, },
pages::{LAZY_IMAGE_JS, backlinks, footer}, pages::page_wrapper,
routes::{IMG_ICON, MAIN_CSS},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -114,7 +113,11 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
return Ok(res); return Ok(res);
} }
fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &RenderContext) -> Markup { fn build_list_for_group(
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 {
@@ -175,7 +178,7 @@ fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &Render
// MARK: page // MARK: page
// //
pub static HANDOUTS: LazyLock<HtmlPage> = LazyLock::new(|| { pub fn handouts() -> Page {
let md = Markdown::parse(include_str!("handouts.md")); let md = Markdown::parse(include_str!("handouts.md"));
let index = CachedRequest::new( let index = CachedRequest::new(
@@ -189,29 +192,19 @@ pub static HANDOUTS: LazyLock<HtmlPage> = LazyLock::new(|| {
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(IMG_ICON.route().into()); meta.image = Some("/assets/img/icon.png".to_owned());
} }
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
HtmlPage::default() Page {
.with_style_linked(MAIN_CSS.route()) meta,
.with_script_inline(LAZY_IMAGE_JS) html_ttl: Some(TimeDelta::seconds(300)),
.with_meta(meta) immutable: false,
.with_render(move |page, ctx| {
let html = html.clone();
let index = index.clone();
render(html, index, page, ctx)
})
.with_ttl(Some(TimeDelta::seconds(300)))
});
fn render<'a>( generate_html: Box::new(move |page, ctx| {
html: Markup, let html = html.clone(); // TODO: find a way to not clone here
index: Arc<CachedRequest<Result<Vec<HandoutEntry>, reqwest::Error>>>, let index = index.clone();
_page: &'a HtmlPage,
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;
@@ -241,9 +234,8 @@ fn render<'a>(
Err(_) => fallback, Err(_) => fallback,
}; };
html! { let inner = html! {
div class="wrapper" style="margin-top:3ex;" { @if let Some(backlinks) = backlinks(page, ctx) {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks) (backlinks)
} }
@@ -268,8 +260,10 @@ fn render<'a>(
))) )))
(advanced) (advanced)
br {} br {}
(footer()) };
}
} page_wrapper(&page.meta, inner, true).await
}) })
}),
}
} }

View File

@@ -5,44 +5,40 @@ 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: 5rem; margin-bottom: 5rem"/> <hr style="margin-top: 8rem; margin-bottom: 8rem"/>
## Projects ## Projects
- **TetrOS**, bare-metal tetris on 32-bit x86. _{{color(--grey, "[author]")}}_
- {{color(--grey, "Status: ")}} {{color(--green, "Done.")}}
- {{color(--grey, "Repository: ")}} [:fa-github: tetros](https://git.betalupi.com/Mark/tetros)
- {{color(--grey, "Quick demo:")}}
`wget https://git.betalupi.com/api/packages/Mark/generic/tetros/latest/disk.img && qemu-system-i386 -d cpu_reset -no-reboot -smp 1 -m 2048 -machine q35 -net none -serial stdio -fda "disk.img"`
<br/>
- **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/)
<br/> <br/>
- **Tectonic**, the LaTeX engine that is pleasant to use. - **Tectonic**, the LaTeX engine that is pleasant to use.
Experimental, but fully functional. _{{color(--grey, "[co-maintainer]")}}_ Experimental, but fully functional. _{{color(--grey, "[co-maintainer]")}}_
- {{color(--grey, "Status: ")}} {{color(--cyan, "Abandoned. ")}} LaTeX is legacy, use [Typst](https://github.com/typst/typst). - {{color(--grey, "Status: ")}} {{color(--yellow, "Abandoned. ")}} [Typst](https://github.com/typst/typst) is better.
- {{color(--grey, "Main repo: ")}} [:fa-github: Tectonic](https://github.com/tectonic-typesetting/tectonic) - {{color(--grey, "Main repo: ")}} [:fa-github: Tectonic](https://github.com/tectonic-typesetting/tectonic)
- {{color(--grey, "Bundle tools: ")}} [:fa-github: tectonic-texlive-bundles](https://github.com/tectonic-typesetting/tectonic-texlive-bundles) - {{color(--grey, "Bundle tools: ")}} [:fa-github: tectonic-texlive-bundles](https://github.com/tectonic-typesetting/tectonic-texlive-bundles)
<br/> <br/>
- **Daisy**, a pretty TUI scientific calculator. _{{color(--grey, "[author]")}}_ - **Daisy**, a pretty TUI scientific calculator. _{{color(--grey, "[author]")}}_
- {{color(--grey, "Status: ")}} {{color(--green, "Done. ")}} Used this to learn Rust. [Numbat](https://numbat.dev) is better. - {{color(--grey, "Status: ")}} {{color(--orange, "Done. ")}} Used this to learn Rust. [Numbat](https://numbat.dev) is better.
- {{color(--grey, "Repository: ")}} [:fa-github: rm-dr/daisy](https://github.com/rm-dr/daisy) - {{color(--grey, "Repository: ")}} [:fa-github: rm-dr/daisy](https://github.com/rm-dr/daisy)
- {{color(--grey, "Website: ")}} [:fa-link: daisy.betalupi.com](https://daisy.betalupi.com) (WASM demo) - {{color(--grey, "Website: ")}} [:fa-link: daisy.betalupi.com](https://daisy.betalupi.com) (WASM demo)
<br/> <br/>
- **Lamb**, a lambda calculus engine. _{{color(--grey, "[author] ")}}_ - **Lamb**, a lambda calculus engine. _{{color(--grey, "[author] ")}}_
- {{color(--grey, "Status: ")}} {{color(--green, "Done. ")}} Fun little project. - {{color(--grey, "Status: ")}} {{color(--orange, "Done. ")}} Fun little project.
- {{color(--grey, "Repository: ")}} [:fa-github: rm-dr/lamb](https://github.com/rm-dr/lamb) - {{color(--grey, "Repository: ")}} [:fa-github: rm-dr/lamb](https://github.com/rm-dr/lamb)
- {{color(--grey, "PyPi: ")}} [:fa-python: lamb-engine](https://pypi.org/project/lamb-engine) - {{color(--grey, "PyPi: ")}} [:fa-python: lamb-engine](https://pypi.org/project/lamb-engine)

View File

@@ -1,6 +1,5 @@
use maud::{Markup, html}; use maud::html;
use servable::{HtmlPage, PageMetadata, RenderContext}; use page::page::{Page, PageMetadata};
use std::{pin::Pin, sync::LazyLock};
use crate::{ use crate::{
components::{ components::{
@@ -9,32 +8,24 @@ use crate::{
md::Markdown, md::Markdown,
misc::FarLink, misc::FarLink,
}, },
pages::{LAZY_IMAGE_JS, PAGE_TTL, footer}, pages::page_wrapper,
routes::{IMG_ICON, MAIN_CSS},
}; };
pub static INDEX: LazyLock<HtmlPage> = LazyLock::new(|| { pub fn index() -> Page {
HtmlPage::default() Page {
.with_style_linked(MAIN_CSS.route()) meta: PageMetadata {
.with_script_inline(LAZY_IMAGE_JS)
.with_meta(PageMetadata {
title: "Betalupi: About".into(), title: "Betalupi: About".into(),
author: Some("Mark".into()), author: Some("Mark".into()),
description: None, description: None,
image: Some(IMG_ICON.route().into()), image: Some("/assets/img/icon.png".to_owned()),
}) backlinks: Some(false),
.with_render(render) },
.with_ttl(PAGE_TTL)
});
fn render<'a>( generate_html: Box::new(move |page, _ctx| {
_page: &'a HtmlPage,
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
Box::pin(async { Box::pin(async {
html! { let inner = html! {
div class="wrapper" style="margin-top:3ex;" {
h2 id="about" { "About" } h2 id="about" { "About" }
div { div {
img img
@@ -81,9 +72,12 @@ fn render<'a>(
} }
(Markdown(include_str!("index.md"))) (Markdown(include_str!("index.md")))
};
(footer()) page_wrapper(&page.meta, inner, true).await
}
}
}) })
}),
..Default::default()
}
} }

View File

@@ -1,33 +1,54 @@
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{Markup, PreEscaped, html}; use maud::{DOCTYPE, Markup, PreEscaped, html};
use reqwest::StatusCode; use page::page::{Page, PageMetadata};
use servable::{HtmlPage, PageMetadata, RenderContext}; use std::pin::Pin;
use std::sync::LazyLock;
use crate::{ use crate::components::{
components::{ md::{Markdown, backlinks, meta_from_markdown},
fa::FAIcon,
md::{Markdown, meta_from_markdown},
misc::FarLink, misc::FarLink,
},
routes::{IMG_ICON, MAIN_CSS},
}; };
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;
/// Default ttl for html pages. pub fn links() -> Page {
/// Dynamic pages should set their own, shorter ttl. /*
pub const PAGE_TTL: Option<TimeDelta> = Some(TimeDelta::days(1)); Dead links:
https://www.commitstrip.com/en/
http://www.3dprintmath.com/
*/
page_from_markdown(
include_str!("links.md"),
Some("/assets/img/icon.png".to_owned()),
)
}
pub fn betalupi() -> Page {
page_from_markdown(
include_str!("betalupi.md"),
Some("/assets/img/icon.png".to_owned()),
)
}
pub fn htwah_typesetting() -> Page {
page_from_markdown(
include_str!("htwah-typesetting.md"),
Some("/assets/img/icon.png".to_owned()),
)
}
// //
// MARK: md // MARK: md
// //
fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> HtmlPage { fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> Page {
let md: String = md.into(); let md: String = md.into();
let md = Markdown::parse(&md); let md = Markdown::parse(&md);
@@ -44,35 +65,59 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> H
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
HtmlPage::default() Page {
.with_script_inline(LAZY_IMAGE_JS) meta,
.with_style_linked(MAIN_CSS.route()) immutable: true,
.with_meta(meta)
.with_render(move |_page, ctx| { html_ttl: Some(TimeDelta::days(1)),
generate_html: Box::new(move |page, ctx| {
let html = html.clone(); let html = html.clone();
Box::pin(async move { Box::pin(async move {
html! { let inner = html! {
div class="wrapper" style="margin-top:3ex;" { @if let Some(backlinks) = backlinks(page, ctx) {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks) (backlinks)
} }
(html) (html)
};
(footer()) page_wrapper(&page.meta, inner, true).await
}
}
}) })
}) }),
.with_ttl(PAGE_TTL) }
} }
// //
// MARK: components // MARK: wrapper
// //
const LAZY_IMAGE_JS: &str = " pub fn page_wrapper<'a>(
meta: &'a PageMetadata,
inner: Markup,
footer: bool,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move {
html! {
(DOCTYPE)
html {
head {
meta charset="UTF8" {}
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=("/assets/css/main.css") {}
(&meta)
title { (PreEscaped(meta.title.clone())) }
// Use a small blurred placeholder while full-size images load.
// Requires no other special scripts or css, just add some tags
// to your <img>!
script {
(PreEscaped("
window.onload = function() { window.onload = function() {
var imgs = document.querySelectorAll('.img-placeholder'); var imgs = document.querySelectorAll('.img-placeholder');
@@ -89,128 +134,42 @@ const LAZY_IMAGE_JS: &str = "
}; };
}) })
} }
"; "))
/*
const MAIN_TEMPLATE: PageTemplate = PageTemplate {
// Order matters, base htmx goes first
scripts: &[
ScriptSource::Linked(&"/assets/htmx-2.0.8.js"),
ScriptSource::Linked(&"/assets/htmx-json-1.19.12.js"),
],
extra_meta: &[(
"viewport",
"width=device-width,initial-scale=1,user-scalable=no",
)],
};
*/
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
}
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) }
} }
} }
})
}
pub fn footer() -> Markup { body {
html!( main{
footer style="margin-top:10rem;" { div class="wrapper" style=(
hr class = "footline"; // for 404 page. Margin makes it scroll.
match footer {
true => "margin-top:3ex;",
false =>""
}
) {
(inner)
@if footer {
footer {
hr class = "footline" {}
div class = "footContainer" { div class = "footContainer" {
p { p {
"This site was built by hand with " "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!"
)
)
)
} }
} }
} }
) }
}
}}
}
}
})
} }
//
// MARK: pages
//
pub const LINKS: LazyLock<HtmlPage> = LazyLock::new(|| {
/*
Dead links:
https://www.commitstrip.com/en/
http://www.3dprintmath.com/
*/
page_from_markdown(include_str!("links.md"), Some(IMG_ICON.route().into()))
});
pub const BETALUPI: LazyLock<HtmlPage> = LazyLock::new(|| {
page_from_markdown(include_str!("betalupi.md"), Some(IMG_ICON.route().into()))
});
pub const HTWAH_TYPESETTING: LazyLock<HtmlPage> = LazyLock::new(|| {
page_from_markdown(
include_str!("htwah-typesetting.md"),
Some(IMG_ICON.route().into()),
)
});
pub static NOT_FOUND: LazyLock<HtmlPage> = LazyLock::new(|| {
HtmlPage::default()
.with_style_linked(MAIN_CSS.route())
.with_meta(PageMetadata {
title: "Page not found".into(),
author: None,
description: None,
image: Some(IMG_ICON.route().into()),
})
.with_render(
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"}
}
}
}
})
}
)
.with_code(StatusCode::NOT_FOUND)
});

View File

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

View File

@@ -1,286 +1,193 @@
use axum::Router; use axum::Router;
use servable::{ use macro_sass::sass;
CACHE_BUST_STR, Redirect, ServableRouter, ServableWithRoute, StaticAsset, mime::MimeType, use page::{PageServer, asset::StaticAsset, redirect::Redirect};
}; use std::sync::Arc;
use tower_http::compression::{CompressionLayer, DefaultPredicate}; use toolbox::mime::MimeType;
use crate::pages; use crate::pages;
pub(super) fn router() -> Router<()> { pub(super) fn router() -> Router<()> {
let compression: CompressionLayer = CompressionLayer::new() build_server().into_router()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
build_server().into_router().layer(compression)
} }
pub static HTMX: ServableWithRoute<StaticAsset> = ServableWithRoute::new( fn build_server() -> Arc<PageServer> {
|| "/assets/htmx-2.0.8.js".into(), let server = PageServer::new();
servable::HTMX_2_0_8.with_ttl(None),
);
pub static HTMX_JSON: ServableWithRoute<StaticAsset> = ServableWithRoute::new( #[expect(clippy::unwrap_used)]
|| "/assets/htmx-json-1.19.12.js".into(), server
servable::EXT_JSON_1_19_12, .with_404(pages::notfound())
); .add_page("/", pages::index())
.add_page("/links", pages::links())
pub static MAIN_CSS: ServableWithRoute<StaticAsset> = ServableWithRoute::new( .add_page("/whats-a-betalupi", pages::betalupi())
|| format!("/assets/{}/css/main.css", *CACHE_BUST_STR), .add_page("/handouts", pages::handouts())
.add_page("/htwah", Redirect::new("/handouts").unwrap())
.add_page("/htwah/typesetting", pages::htwah_typesetting())
//
.add_page(
"/assets/css/main.css",
StaticAsset { StaticAsset {
bytes: grass::include!("css/main.scss").as_bytes(), bytes: sass!("css/main.scss").as_bytes(),
mime: MimeType::Css, mime: MimeType::Css,
ttl: StaticAsset::DEFAULT_TTL,
}, },
); )
.add_page(
pub static IMG_COVER_SMALL: ServableWithRoute<StaticAsset> = ServableWithRoute::new( "/assets/img/cover-small.jpg",
|| "/assets/img/cover-small.jpg".into(),
StaticAsset { StaticAsset {
bytes: include_bytes!("../../assets/images/cover-small.jpg"), bytes: include_bytes!("../../assets/images/cover-small.jpg"),
mime: MimeType::Jpg, mime: MimeType::Jpg,
ttl: StaticAsset::DEFAULT_TTL,
}, },
); )
.add_page(
pub static IMG_BETALUPI: ServableWithRoute<StaticAsset> = ServableWithRoute::new( "/assets/img/betalupi.png",
|| "/assets/img/betalupi.png".into(),
StaticAsset { StaticAsset {
bytes: include_bytes!("../../assets/images/betalupi-map.png"), bytes: include_bytes!("../../assets/images/betalupi-map.png"),
mime: MimeType::Png, mime: MimeType::Png,
ttl: StaticAsset::DEFAULT_TTL,
}, },
); )
.add_page(
pub static IMG_ICON: ServableWithRoute<StaticAsset> = ServableWithRoute::new( "/assets/img/icon.png",
|| "/assets/img/icon.png".into(),
StaticAsset { StaticAsset {
bytes: include_bytes!("../../assets/images/icon.png"), bytes: include_bytes!("../../assets/images/icon.png"),
mime: MimeType::Png, mime: MimeType::Png,
ttl: StaticAsset::DEFAULT_TTL,
}, },
); )
//
// MARK: fonts
//
pub static FONT_FIRACODE_BOLD: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/FiraCode-Bold.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Bold.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_LIGHT: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/FiraCode-Light.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Light.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_MEDIUM: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/FiraCode-Medium.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Medium.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_REGULAR: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/FiraCode-Regular.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Regular.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_SEMIBOLD: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/FiraCode-SemiBold.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-SemiBold.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_VF: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/FiraCode-VF.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-VF.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
//
// MARK: icons
//
pub static FONT_FA_BRANDS_WOFF2: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/fa/fa-brands-400.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FA_REGULAR_WOFF2: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/fa/fa-regular-400.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FA_SOLID_WOFF2: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/fa/fa-solid-900.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.woff2"),
mime: MimeType::Woff2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FA_BRANDS_TTF: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/fa/fa-brands-400.ttf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.ttf"),
mime: MimeType::Ttf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FA_REGULAR_TTF: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/fa/fa-regular-400.ttf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.ttf"),
mime: MimeType::Ttf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FA_SOLID_TTF: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/fonts/fa/fa-solid-900.ttf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.ttf"),
mime: MimeType::Ttf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
//
// MARK: htwah
//
pub static HTWAH_DEFINITIONS: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/htwah/definitions.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/definitions.pdf"),
mime: MimeType::Pdf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_NUMBERING: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/htwah/numbering.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/numbering.pdf"),
mime: MimeType::Pdf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SOLS_A: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/htwah/sols-a.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/sols-a.pdf"),
mime: MimeType::Pdf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SOLS_B: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/htwah/sols-b.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/sols-b.pdf"),
mime: MimeType::Pdf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SPACING_A: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/htwah/spacing-a.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/spacing-a.pdf"),
mime: MimeType::Pdf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SPACING_B: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/assets/htwah/spacing-b.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/spacing-b.pdf"),
mime: MimeType::Pdf,
ttl: StaticAsset::DEFAULT_TTL,
},
);
fn build_server() -> ServableRouter {
ServableRouter::new()
.with_404(&pages::NOT_FOUND)
.add_page("/", &pages::INDEX)
.add_page("/links", pages::LINKS)
.add_page("/whats-a-betalupi", pages::BETALUPI)
.add_page("/handouts", &pages::HANDOUTS)
.add_page("/htwah", {
#[expect(clippy::unwrap_used)]
Redirect::new("/handouts").unwrap()
})
.add_page("/htwah/typesetting", pages::HTWAH_TYPESETTING)
.add_page_with_route(&HTMX)
.add_page_with_route(&HTMX_JSON)
//
.add_page_with_route(&MAIN_CSS)
.add_page_with_route(&IMG_COVER_SMALL)
.add_page_with_route(&IMG_BETALUPI)
.add_page_with_route(&IMG_ICON)
// //
// MARK: fonts // MARK: fonts
// //
.add_page_with_route(&FONT_FIRACODE_BOLD) .add_page(
.add_page_with_route(&FONT_FIRACODE_LIGHT) "/assets/fonts/FiraCode-Bold.woff2",
.add_page_with_route(&FONT_FIRACODE_MEDIUM) StaticAsset {
.add_page_with_route(&FONT_FIRACODE_REGULAR) bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Bold.woff2"),
.add_page_with_route(&FONT_FIRACODE_SEMIBOLD) mime: MimeType::Woff2,
.add_page_with_route(&FONT_FIRACODE_VF) },
)
.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 // MARK: icons
// //
.add_page_with_route(&FONT_FA_BRANDS_WOFF2) .add_page(
.add_page_with_route(&FONT_FA_REGULAR_WOFF2) "/assets/fonts/fa/fa-brands-400.woff2",
.add_page_with_route(&FONT_FA_SOLID_WOFF2) StaticAsset {
.add_page_with_route(&FONT_FA_BRANDS_TTF) bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.woff2"),
.add_page_with_route(&FONT_FA_REGULAR_TTF) mime: MimeType::Woff2,
.add_page_with_route(&FONT_FA_SOLID_TTF) },
)
.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 // MARK: htwah
// //
.add_page_with_route(&HTWAH_DEFINITIONS) .add_page(
.add_page_with_route(&HTWAH_NUMBERING) "/assets/htwah/definitions.pdf",
.add_page_with_route(&HTWAH_SOLS_A) StaticAsset {
.add_page_with_route(&HTWAH_SOLS_B) bytes: include_bytes!("../../assets/htwah/definitions.pdf"),
.add_page_with_route(&HTWAH_SPACING_A) mime: MimeType::Pdf,
.add_page_with_route(&HTWAH_SPACING_B) },
)
.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,
},
);
server
} }
#[test] #[test]
@@ -290,6 +197,7 @@ fn server_builds_without_panic() {
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
let _server = build_server(); // Needs tokio context
let _server = build_server().into_router();
}); });
} }

View File

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