Compare commits

...

10 Commits

Author SHA1 Message Date
8bcd6ada5d TMP pile 2026-03-23 22:35:15 -07:00
7cb1dfa2d1 merge 2026-03-23 10:18:55 -07:00
67e63019c5 Refactor, create service-assets 2026-03-23 10:00:16 -07:00
368034a177 Update deps 2026-03-21 10:54:22 -07:00
cdc2e40803 Add link 2026-03-21 10:54:03 -07:00
a29db858d1 Migrate to servable
Some checks failed
CI / Check typos (push) Successful in 15s
CI / Check links (push) Failing after 1m37s
CI / Clippy (push) Successful in 3m44s
CI / Build and test (push) Successful in 12m33s
CI / Build container (push) Successful in 10m24s
CI / Deploy on waypoint (push) Successful in 49s
2025-11-27 21:07:52 -08:00
b6de727883 page rewrite
Some checks failed
CI / Check typos (push) Failing after 8m53s
CI / Check links (push) Failing after 8m51s
CI / Clippy (push) Failing after 7m21s
CI / Build and test (push) Failing after 7m18s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
2025-11-16 12:58:20 -08:00
04d98462dd Return 404 for 404 page
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 13s
CI / Clippy (push) Successful in 54s
CI / Build and test (push) Successful in 1m20s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
2025-11-14 09:46:18 -08:00
991eb92562 Tweaks
Some checks failed
CI / Check typos (push) Successful in 28s
CI / Check links (push) Failing after 31s
CI / Clippy (push) Successful in 1m2s
CI / Build and test (push) Successful in 1m21s
CI / Build container (push) Successful in 1m5s
CI / Deploy on waypoint (push) Successful in 44s
2025-11-12 14:18:44 -08:00
529dfc468e README 2025-11-12 14:18:41 -08:00
92 changed files with 2455 additions and 3401 deletions

1455
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/macro/*", "crates/service/*"] members = ["crates/bin/*", "crates/lib/*", "crates/service/*"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
@@ -40,7 +40,6 @@ 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"
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"
@@ -57,6 +56,7 @@ unimplemented = "deny"
unwrap_used = "warn" unwrap_used = "warn"
expect_used = "warn" expect_used = "warn"
type_complexity = "allow" type_complexity = "allow"
implicit_clone = "deny"
# #
@@ -64,22 +64,25 @@ 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" }
service-pile = { path = "crates/service/service-pile" }
service-assets = { path = "crates/service/service-assets" }
pile-client = { git = "https://git.betalupi.com/Mark/pile.git", rev = "90c5584513acde6f30f76d70c426cf6987643c1a" }
# #
# MARK: Server # MARK: Server
# #
axum = { version = "0.8.6", features = ["macros", "multipart"] } axum = { version = "0.8.8", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace", "compression-full"] } tower-http = { version = "0.6.8", features = ["trace", "compression-full"] }
tower = { version = "0.5.3" }
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",
@@ -87,12 +90,13 @@ 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 = "0.13.4" grass = { version = "0.13.4", features = ["macro"] }
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.28", 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",
@@ -100,24 +104,29 @@ reqwest = { version = "0.12.24", default-features = false, features = [
"charset", "charset",
"blocking", "blocking",
] } ] }
servable = { version = "0.0.7", features = ["image", "htmx-2.0.8"] }
#servable = { path = "../servable/crates/servable", features = [
# "image",
# "htmx-2.0.8",
#] }
# #
# MARK: Async & Parallelism # MARK: Async & Parallelism
# #
tokio = { version = "1.48.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
# #
# MARK: CLI & logging # MARK: CLI & logging
# #
tracing = "0.1.41" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
tracing-loki = { version = "0.2.6", features = [ tracing-loki = { version = "0.2.6", features = [
"rustls", "rustls",
"compat-0-2-1", "compat-0-2-1",
], default-features = false } ], default-features = false }
clap = { version = "4.5.51", features = ["derive"] } clap = { version = "4.6.0", features = ["derive"] }
anstyle = { version = "1.0.13" } anstyle = { version = "1.0.14" }
envy = "0.4.2" envy = "0.4.2"
dotenvy = "0.15.7" dotenvy = "0.15.7"
@@ -125,8 +134,8 @@ dotenvy = "0.15.7"
# MARK: Serialization & formats # MARK: Serialization & formats
# #
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145" serde_json = "1.0.149"
toml = "0.9.8" toml = "0.9.12"
serde_yaml = "0.9" serde_yaml = "0.9"
base64 = "0.22.1" base64 = "0.22.1"
@@ -134,16 +143,20 @@ base64 = "0.22.1"
# MARK: Misc helpers # MARK: Misc helpers
# #
strum = { version = "0.27", features = ["derive"] } strum = { version = "0.27", features = ["derive"] }
thiserror = "2.0.12" thiserror = "2.0.18"
itertools = "0.14.0" itertools = "0.14.0"
anyhow = "1.0.97" anyhow = "1.0.102"
url = { version = "2.5.7", features = ["serde"] } url = { version = "2.5.8", features = ["serde"] }
num = "0.4.3" num = "0.4.3"
chrono = "0.4.42" chrono = "0.4.44"
lru = "0.16.2" lru = "0.16.3"
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.10"
scraper = "0.24.0"
futures = "0.3.32"
tempfile = "3.27.0"
mime = "0.3.17"
# md_* test utilities # md_* test utilities
prettydiff = "0.9.0" prettydiff = "0.9.0"
@@ -152,7 +165,7 @@ testing = "18.0.0"
# #
# Macro utilities # Macro utilities
# #
proc-macro2 = "1.0.95" proc-macro2 = "1.0.106"
syn = "2.0.101" syn = "2.0.117"
quote = "1.0.40" quote = "1.0.45"
paste = "1.0.15" paste = "1.0.15"

50
README.md Normal file
View File

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

@@ -10,7 +10,10 @@ workspace = true
[dependencies] [dependencies]
toolbox = { workspace = true, features = ["cli", "loki"] } toolbox = { workspace = true, features = ["cli", "loki"] }
libservice = { workspace = true } libservice = { workspace = true }
service-webpage = { workspace = true } service-webpage = { workspace = true }
service-assets = { workspace = true }
service-pile = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -1,5 +1,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use libservice::{Service, ServiceConnectInfo, ToService}; use libservice::{Service, ServiceConnectInfo, ToService};
use service_assets::AssetService;
use service_pile::PileService;
use service_webpage::WebpageService; use service_webpage::WebpageService;
use std::sync::Arc; use std::sync::Arc;
use tracing::{error, info}; use tracing::{error, info};
@@ -70,6 +72,16 @@ pub struct RouterState {}
/// If state is none, dry-init /// If state is none, dry-init
pub async fn make_service(_state: Option<Arc<RouterState>>) -> Result<impl ToService> { pub async fn make_service(_state: Option<Arc<RouterState>>) -> Result<impl ToService> {
let service_webpage = WebpageService::new(); let service_webpage = WebpageService::new();
let service_assets = AssetService::new();
let service_pile = PileService::new()
.await
.map_err(|e| anyhow::anyhow!(e))
.context("while initializing pile datasets")?;
Ok(Service::new().merge(service_webpage).to_service().trace()) Ok(Service::new()
.merge(service_webpage)
.nest("/assets", service_assets)
.nest("/pile", service_pile)
.to_service()
.trace())
} }

View File

@@ -160,6 +160,7 @@ impl BlockRule for FootnoteDefinitionScanner {
} }
#[cfg(test)] #[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests { mod tests {
use super::*; use super::*;

View File

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

View File

@@ -1,64 +0,0 @@
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

@@ -1,116 +0,0 @@
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

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

View File

@@ -1,127 +0,0 @@
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

@@ -1,41 +0,0 @@
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

@@ -1,324 +0,0 @@
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

@@ -1,16 +0,0 @@
[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

@@ -1,145 +0,0 @@
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

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

View File

@@ -1,68 +0,0 @@
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

@@ -1,184 +0,0 @@
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

@@ -1,82 +0,0 @@
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

@@ -1,165 +0,0 @@
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,7 +12,6 @@ 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,8 +1,6 @@
//! 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

@@ -8,21 +8,16 @@ use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::Subscribe
// MARK: loglevel // MARK: loglevel
// //
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, ValueEnum)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, ValueEnum, Default)]
pub enum LogLevel { pub enum LogLevel {
Trace, Trace,
Debug, Debug,
#[default]
Info, Info,
Warn, Warn,
Error, Error,
} }
impl Default for LogLevel {
fn default() -> Self {
Self::Info
}
}
impl Display for LogLevel { impl Display for LogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
@@ -47,6 +42,7 @@ pub struct LoggingConfig {
// Libs // Libs
pub libservice: LogLevel, pub libservice: LogLevel,
pub servable: LogLevel,
pub toolbox: LogLevel, pub toolbox: LogLevel,
// Bins // Bins
@@ -70,15 +66,24 @@ 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),
format!("tantivy={}", conf.silence),
format!("aws_smithy_runtime={}", conf.silence),
format!("aws_smithy_http_client={}", conf.silence),
format!("aws_sdk_s3={}", conf.silence),
format!("aws_sigv4={}", conf.silence),
// //
// Libs // Libs
// //
format!("toolbox={}", conf.toolbox), format!("toolbox={}", conf.toolbox),
format!("libservice={}", conf.libservice), format!("libservice={}", conf.libservice),
format!("servable={}", conf.servable),
// //
// Bins // Bins
// //
format!("service_webpage={}", conf.service), format!("service_webpage={}", conf.service),
format!("service_pile={}", conf.service),
format!("webpage={}", conf.webpage), format!("webpage={}", conf.webpage),
conf.other.to_string(), conf.other.to_string(),
] ]
@@ -186,6 +191,7 @@ impl LogFilterPreset {
// Libs // Libs
libservice: LogLevel::Error, libservice: LogLevel::Error,
servable: LogLevel::Error,
toolbox: LogLevel::Error, toolbox: LogLevel::Error,
// Bins // Bins
@@ -199,6 +205,7 @@ impl LogFilterPreset {
// Libs // Libs
libservice: LogLevel::Warn, libservice: LogLevel::Warn,
servable: LogLevel::Warn,
toolbox: LogLevel::Warn, toolbox: LogLevel::Warn,
// Bins // Bins
@@ -212,6 +219,7 @@ impl LogFilterPreset {
// Libs // Libs
libservice: LogLevel::Info, libservice: LogLevel::Info,
servable: LogLevel::Info,
toolbox: LogLevel::Info, toolbox: LogLevel::Info,
// Bins // Bins
@@ -225,6 +233,7 @@ impl LogFilterPreset {
// Libs // Libs
libservice: LogLevel::Debug, libservice: LogLevel::Debug,
servable: LogLevel::Debug,
toolbox: LogLevel::Debug, toolbox: LogLevel::Debug,
// Bins // Bins
@@ -238,6 +247,7 @@ impl LogFilterPreset {
// Libs // Libs
libservice: LogLevel::Trace, libservice: LogLevel::Trace,
servable: LogLevel::Trace,
toolbox: LogLevel::Trace, toolbox: LogLevel::Trace,
// Bins // Bins
@@ -251,6 +261,7 @@ impl LogFilterPreset {
// Libs // Libs
libservice: LogLevel::Trace, libservice: LogLevel::Trace,
servable: LogLevel::Trace,
toolbox: LogLevel::Trace, toolbox: LogLevel::Trace,
// Bins // Bins
@@ -264,6 +275,7 @@ impl LogFilterPreset {
// Libs // Libs
libservice: LogLevel::Trace, libservice: LogLevel::Trace,
servable: LogLevel::Trace,
toolbox: LogLevel::Trace, toolbox: LogLevel::Trace,
// Bins // Bins
@@ -304,19 +316,14 @@ pub enum LoggingTarget {
} }
/// How to print logs /// How to print logs
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize, Default)]
pub enum LoggingFormat { pub enum LoggingFormat {
#[default]
Ansi, Ansi,
AnsiNoColor, AnsiNoColor,
Json, Json,
} }
impl Default for LoggingFormat {
fn default() -> Self {
Self::Ansi
}
}
pub struct LoggingInitializer { pub struct LoggingInitializer {
pub app_name: &'static str, pub app_name: &'static str,

View File

@@ -1,690 +0,0 @@
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

@@ -1,36 +0,0 @@
/// 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

@@ -1,16 +0,0 @@
[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

@@ -1,149 +0,0 @@
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

@@ -0,0 +1,22 @@
[package]
name = "service-assets"
version = { workspace = true }
rust-version = { workspace = true }
edition = { workspace = true }
[lints]
workspace = true
[dependencies]
libservice = { workspace = true }
axum = { workspace = true }
maud = { workspace = true }
strum = { workspace = true }
tower-http = { workspace = true }
servable = { workspace = true }
mime = { workspace = true }
grass = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,28 @@
@import "fontawesome/fontawesome";
@import "fontawesome/brands";
@import "fontawesome/regular";
@import "fontawesome/solid";
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Bold.woff2") format("woff2");
font-weight: bold;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Light.woff2") format("woff2");
font-weight: light;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Medium.woff2") format("woff2");
font-weight: medium;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Regular.woff2") format("woff2");
font-weight: normal;
}

View File

@@ -0,0 +1,28 @@
@import "fontawesome/fontawesome";
@import "fontawesome/brands";
@import "fontawesome/regular";
@import "fontawesome/solid";
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Bold.woff2") format("woff2");
font-weight: bold;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Light.woff2") format("woff2");
font-weight: light;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Medium.woff2") format("woff2");
font-weight: medium;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Regular.woff2") format("woff2");
font-weight: normal;
}

View File

@@ -0,0 +1,55 @@
use servable::{ServableWithRoute, StaticAsset};
pub static FONT_FIRACODE_BOLD: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/FiraCode-Bold.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Bold.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_LIGHT: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/FiraCode-Light.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Light.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_MEDIUM: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/FiraCode-Medium.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Medium.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_REGULAR: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/FiraCode-Regular.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Regular.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_SEMIBOLD: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/FiraCode-SemiBold.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-SemiBold.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FIRACODE_VF: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/FiraCode-VF.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-VF.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);

View File

@@ -0,0 +1,68 @@
use std::str::FromStr;
use std::sync::LazyLock;
use mime::Mime;
use servable::{ServableWithRoute, StaticAsset};
pub static FONT_FA_BRANDS_WOFF2: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/fa/fa-brands-400.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FA_REGULAR_WOFF2: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/fa/fa-regular-400.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static FONT_FA_SOLID_WOFF2: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/fonts/fa/fa-solid-900.woff2".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.woff2"),
mime: mime::FONT_WOFF2,
ttl: StaticAsset::DEFAULT_TTL,
},
);
#[expect(clippy::unwrap_used)]
pub static FONT_FA_BRANDS_TTF: LazyLock<ServableWithRoute<StaticAsset>> = LazyLock::new(|| {
ServableWithRoute::new(
|| "/fonts/fa/fa-brands-400.ttf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.ttf"),
mime: Mime::from_str("font/ttf").unwrap(),
ttl: StaticAsset::DEFAULT_TTL,
},
)
});
#[expect(clippy::unwrap_used)]
pub static FONT_FA_REGULAR_TTF: LazyLock<ServableWithRoute<StaticAsset>> = LazyLock::new(|| {
ServableWithRoute::new(
|| "/fonts/fa/fa-regular-400.ttf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.ttf"),
mime: Mime::from_str("font/ttf").unwrap(),
ttl: StaticAsset::DEFAULT_TTL,
},
)
});
#[expect(clippy::unwrap_used)]
pub static FONT_FA_SOLID_TTF: LazyLock<ServableWithRoute<StaticAsset>> = LazyLock::new(|| {
ServableWithRoute::new(
|| "/fonts/fa/fa-solid-900.ttf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.ttf"),
mime: Mime::from_str("font/ttf").unwrap(),
ttl: StaticAsset::DEFAULT_TTL,
},
)
});

View File

@@ -0,0 +1,44 @@
use servable::{CACHE_BUST_STR, ServableWithRoute, StaticAsset};
mod firacode;
pub use firacode::*;
mod fontawesome;
pub use fontawesome::*;
pub static HTMX: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/htmx-2.0.8.js".into(),
servable::HTMX_2_0_8.with_ttl(None),
);
pub static HTMX_JSON: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/htmx-json-1.19.12.js".into(),
servable::EXT_JSON_1_19_12,
);
pub static IMG_ICON: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/img/icon.png".into(),
StaticAsset {
bytes: include_bytes!("../../assets/icon.png"),
mime: mime::IMAGE_PNG,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static CSS_FIRA: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| format!("/css/{}/fira.css", *CACHE_BUST_STR),
StaticAsset {
bytes: grass::include!("crates/service/service-assets/css/fira.scss").as_bytes(),
mime: mime::TEXT_CSS,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static CSS_FONTAWESOME: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| format!("/css/{}/fontawesome.css", *CACHE_BUST_STR),
StaticAsset {
bytes: grass::include!("crates/service/service-assets/css/fontawesome.scss").as_bytes(),
mime: mime::TEXT_CSS,
ttl: StaticAsset::DEFAULT_TTL,
},
);

View File

@@ -0,0 +1,20 @@
pub mod fa;
pub mod misc;
pub const LAZY_IMAGE_JS: &str = "
window.onload = function() {
var imgs = document.querySelectorAll('.img-placeholder');
imgs.forEach(img => {
img.style.border = 'none';
img.style.filter = 'blur(10px)';
img.style.transition = 'filter 0.3s';
var lg = new Image();
lg.src = img.dataset.large;
lg.onload = function () {
img.src = img.dataset.large;
img.style.filter = 'blur(0px)';
};
})
}";

View File

@@ -0,0 +1,71 @@
use axum::Router;
use libservice::ToService;
use servable::ServableRouter;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
pub mod assets;
pub mod components;
pub struct AssetService {}
impl AssetService {
#[inline]
pub fn new() -> Self {
Self {}
}
}
impl ToService for AssetService {
#[inline]
fn make_router(&self) -> Option<Router<()>> {
use assets::*;
let router = ServableRouter::new()
.add_page_with_route(&HTMX)
.add_page_with_route(&HTMX_JSON)
.add_page_with_route(&IMG_ICON)
// fira
.add_page_with_route(&CSS_FIRA)
.add_page_with_route(&FONT_FIRACODE_BOLD)
.add_page_with_route(&FONT_FIRACODE_LIGHT)
.add_page_with_route(&FONT_FIRACODE_MEDIUM)
.add_page_with_route(&FONT_FIRACODE_REGULAR)
.add_page_with_route(&FONT_FIRACODE_SEMIBOLD)
.add_page_with_route(&FONT_FIRACODE_VF)
// fa
.add_page_with_route(&CSS_FONTAWESOME)
.add_page_with_route(&FONT_FA_BRANDS_WOFF2)
.add_page_with_route(&FONT_FA_REGULAR_WOFF2)
.add_page_with_route(&FONT_FA_SOLID_WOFF2)
.add_page_with_route(&*FONT_FA_BRANDS_TTF)
.add_page_with_route(&*FONT_FA_REGULAR_TTF)
.add_page_with_route(&*FONT_FA_SOLID_TTF)
.into_router();
let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
Some(router.layer(compression))
}
#[inline]
fn service_name(&self) -> Option<String> {
Some("assets".to_owned())
}
}
#[test]
#[expect(clippy::unwrap_used)]
fn server_builds_without_panic() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let router = AssetService {}.make_router();
assert!(router.is_some())
});
}

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "page" name = "service-pile"
version = { workspace = true } version = { workspace = true }
rust-version = { workspace = true } rust-version = { workspace = true }
edition = { workspace = true } edition = { workspace = true }
@@ -8,16 +8,21 @@ edition = { workspace = true }
workspace = true workspace = true
[dependencies] [dependencies]
toolbox = { workspace = true }
libservice = { workspace = true } libservice = { workspace = true }
pixel-transform = { workspace = true } service-assets = { workspace = true }
pile-client = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
grass = { workspace = true }
axum = { workspace = true }
maud = { workspace = true } maud = { workspace = true }
chrono = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
lru = { workspace = true } reqwest = { workspace = true }
tower-http = { workspace = true } tower-http = { workspace = true }
servable = { workspace = true }
url = { workspace = true }
mime = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,183 @@
@import "text";
:root {
// Misc colors
--bgColor: #121212;
--lightBgColor: #3a3f46;
--fgColor: #ebebeb;
--metaColor: #6199bb;
--lightMetaColor: #638c86;
--linkColor: #e4dab3;
--codeBgColor: #292929;
--codeFgColor: var(--fgColor);
// Main colors
--grey: #696969;
// Accent colors, used only manally
--green: #a2c579;
--magenta: #ad79c5;
--orange: #e86a33;
--yellow: #e8bc00;
--pink: #fa9f83;
}
::selection,
::-moz-selection {
color: var(--bgColor);
background: var(--metaColor);
}
html {
-webkit-box-sizing: border-box;
box-sizing: border-box;
font-size: 62.5%;
scrollbar-color: var(--metaColor) var(--bgColor);
scrollbar-width: auto;
background: var(--bgColor);
}
body {
font-family: "Fira";
font-size: 1.6rem;
line-height: 1.35;
max-width: 64rem;
margin: auto;
overflow-wrap: break-word;
background: var(--bgColor);
color: var(--fgColor);
}
div.wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
@media (max-width: 650px) {
.wrapper {
margin: 1rem;
}
}
// Loading spinner (three dots)
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 0;
}
@keyframes dot-bounce {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.search-meta {
font-size: 1.2rem;
color: var(--grey);
margin: 0 0 1.5em 0;
text-align: left;
}
#search-results {
margin-top: 0;
}
.result-grid {
display: flex;
flex-direction: column;
gap: 0.5em;
margin-top: 1.5em;
}
.result-item {
display: flex;
align-items: stretch;
border: 1px solid var(--lightBgColor);
border-radius: 3px;
overflow: hidden;
height: 64px;
}
.result-item-thumb {
width: 64px;
flex-shrink: 0;
background: var(--lightBgColor);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.result-item-info {
padding: 0.5em 0.8em;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.15em;
min-width: 0;
flex: 1;
}
.result-item-key {
font-family: monospace;
font-size: 1.3rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
color: var(--fgColor);
cursor: pointer;
&:hover {
color: var(--metaColor);
}
}
.result-item-link {
font-family: monospace;
font-size: 1.1rem;
color: var(--linkColor);
text-decoration: none;
align-self: flex-start;
&:hover {
text-decoration: underline;
}
}
.result-sentinel {
height: 1px;
}
#preview-overlay {
display: none;
position: fixed;
z-index: 1000;
pointer-events: none;
border: 1px solid var(--lightBgColor);
background: var(--bgColor);
padding: 4px;
border-radius: 3px;
box-shadow: 0 4px 24px rgba(0,0,0,0.6);
img {
display: block;
max-width: 480px;
max-height: 480px;
object-fit: contain;
}
}

View File

@@ -0,0 +1,29 @@
h1 {
font-size: 3.5rem;
margin-top: 1ex;
margin-bottom: 1ex;
}
h2 {
font-size: 2.5rem;
margin-top: 1ex;
margin-bottom: 0.5ex;
}
h3 {
font-size: 2rem;
}
a {
text-decoration: none;
border-radius: .3rem;
padding: 0 .2ex 0 .2ex;
color: var(--linkColor);
transition: 150ms;
}
a:hover {
background-color: var(--linkColor);
color: var(--bgColor);
transition: 150ms;
}

View File

@@ -0,0 +1,37 @@
use axum::Router;
use libservice::ToService;
use pile_client::PileClient;
use std::sync::Arc;
mod pages;
mod routes;
pub const PILE_PREFIX: &str = "/pile";
pub const ASSET_PREFIX: &str = "/assets";
pub struct PileService {
client: Arc<PileClient>,
}
impl PileService {
pub async fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let endpoint = std::env::var("PILE_ENDPOINT")?;
let api_key = std::env::var("PILE_API_KEY").ok();
let client = PileClient::new(&endpoint, api_key.as_deref())?;
Ok(Self {
client: Arc::new(client),
})
}
}
impl ToService for PileService {
#[inline]
fn make_router(&self) -> Option<Router<()>> {
Some(routes::router(self.client.clone()))
}
#[inline]
fn service_name(&self) -> Option<String> {
Some("pile".to_owned())
}
}

View File

@@ -0,0 +1,110 @@
use maud::{Markup, html};
use servable::{HtmlPage, PageMetadata, RenderContext};
use service_assets::assets::{CSS_FIRA, CSS_FONTAWESOME, HTMX};
use std::{pin::Pin, sync::LazyLock};
use crate::{ASSET_PREFIX, PILE_PREFIX, routes::CSS_PILE};
pub static INDEX: LazyLock<HtmlPage> = LazyLock::new(|| {
HtmlPage::default()
.with_style_linked(CSS_PILE.route_at(PILE_PREFIX))
.with_style_linked(CSS_FIRA.route_at(ASSET_PREFIX))
.with_style_linked(CSS_FONTAWESOME.route_at(ASSET_PREFIX))
.with_script_linked(HTMX.route_at(ASSET_PREFIX))
.with_meta(PageMetadata {
title: "Pile".into(),
author: None,
description: None,
image: None,
})
.with_render(render)
});
fn render<'a>(
_page: &'a HtmlPage,
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
Box::pin(async {
html! {
div class="wrapper" style="margin-top:3ex;" {
div {
div style="
text-align:center;
padding-top:30px;
padding-bottom:60px;
" {
h1 class="brand" {
span class="fa fa-solid fa-book" aria-hidden="true" {}
" Library search"
}
div style="max-width:500px;margin:0 auto;padding:.4em 1em;" {
form {
input
class="search-input"
id="search"
name="q"
type="text"
placeholder="Type to search..."
style="
-moz-box-sizing: border-box !important;
box-sizing: border-box !important;
outline: none;
border: none;
border-radius: 1px;
margin-top: 5px;
margin-bottom: 5px;
padding: 10px 16px;
font-size: 17px;
width: 100%;
box-shadow: 0 0 0 1px var(--color-border),0 0 0 1px var(--color-border);
transition: box-shadow 150ms ease-in-out;
"
autofocus=""
autocomplete="off"
hx-get=(format!("{PILE_PREFIX}/search"))
hx-trigger="load, keyup changed delay:100ms"
hx-target="#search-results"
hx-swap="outerHTML"
hx-indicator="#search-spinner"
{}
}
div id="search-spinner" class="htmx-indicator dot-spinner" {
span {}
span {}
span {}
}
div id="search-results" {}
}
}
}
}
div id="preview-overlay" {
img id="preview-overlay-img" src="" alt="" {}
}
script { (maud::PreEscaped("
function showPreview(el) {
var ov = document.getElementById('preview-overlay');
var img = document.getElementById('preview-overlay-img');
img.src = el.dataset.preview;
var rect = el.getBoundingClientRect();
var size = 480;
var left = rect.right + 12;
if (left + size > window.innerWidth) { left = rect.left - size - 12; }
ov.style.left = left + 'px';
ov.style.top = Math.max(8, rect.top - size / 2 + rect.height / 2) + 'px';
ov.style.display = 'block';
}
function hidePreview() {
var ov = document.getElementById('preview-overlay');
ov.style.display = 'none';
document.getElementById('preview-overlay-img').src = '';
}
")) }
}
})
}

View File

@@ -0,0 +1,37 @@
use maud::html;
use reqwest::StatusCode;
use servable::{HtmlPage, PageMetadata};
use service_assets::assets::{CSS_FIRA, CSS_FONTAWESOME};
use std::sync::LazyLock;
mod index;
pub use index::INDEX;
use crate::{ASSET_PREFIX, PILE_PREFIX, routes::CSS_PILE};
pub static NOT_FOUND: LazyLock<HtmlPage> = LazyLock::new(|| {
HtmlPage::default()
.with_style_linked(CSS_PILE.route_at(PILE_PREFIX))
.with_style_linked(CSS_FIRA.route_at(ASSET_PREFIX))
.with_style_linked(CSS_FONTAWESOME.route_at(ASSET_PREFIX))
.with_meta(PageMetadata {
title: "Page not found".into(),
author: None,
description: None,
image: None,
})
.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,231 @@
use axum::Router;
use axum::extract::{Query, State};
use axum::routing::get;
use maud::{Markup, html};
use pile_client::PileClient;
use servable::{CACHE_BUST_STR, ServableRouter, ServableWithRoute, StaticAsset};
use std::sync::Arc;
use std::time::Instant;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::warn;
use crate::PILE_PREFIX;
use crate::pages;
const PAGE_SIZE: usize = 50;
pub(super) fn router(client: Arc<PileClient>) -> Router<()> {
let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
let search_router: Router<()> = Router::new()
.route("/search", get(search_handler))
.with_state(client.clone());
let api_router: Router<()> = client.dataset("books").proxy_router();
build_server()
.into_router()
.merge(search_router)
.nest("/api", api_router)
.layer(compression)
}
#[derive(serde::Deserialize)]
struct SearchQuery {
#[serde(default)]
q: String,
#[serde(default)]
page: usize,
}
async fn search_handler(
State(client): State<Arc<PileClient>>,
Query(params): Query<SearchQuery>,
) -> Markup {
let start = Instant::now();
let query = params.q.trim().to_lowercase();
let page = params.page;
let mut query_invalid = false;
let mut list_error = false;
let mut all_keys: Vec<(String, String)> = Vec::new();
let mut filtered_total = 0;
let mut grand_total = 0;
match query.is_empty() {
true => {
match client
.dataset("books")
.list_items(page * PAGE_SIZE, PAGE_SIZE)
.await
{
Err(error) => {
list_error = true;
warn!(message = "error while listing items", ?error);
}
Ok(resp) => {
all_keys = resp
.items
.into_iter()
.map(|item| (item.source, item.key))
.collect();
filtered_total = resp.total;
grand_total = resp.total;
}
}
}
false => match client.dataset("books").lookup(&query, Some(512)).await {
Err(_error) => {
query_invalid = true;
}
Ok(resp) => {
let mut results = resp.results;
results.sort_unstable_by(|a, b| f32::total_cmp(&b.score, &a.score));
filtered_total = results.len();
grand_total = results.len();
all_keys = results.into_iter().map(|r| (r.source, r.key)).collect();
}
},
}
// For empty query the server already paginated; for non-empty we slice locally.
let page_items: Vec<&(String, String)> = if query.is_empty() {
all_keys.iter().collect()
} else {
all_keys
.iter()
.skip(page * PAGE_SIZE)
.take(PAGE_SIZE)
.collect()
};
let has_more = (page + 1) * PAGE_SIZE < filtered_total;
let next_page = page + 1;
let encoded_q: String = url::form_urlencoded::byte_serialize(query.as_bytes()).collect();
let next_url = format!("{PILE_PREFIX}/search?q={}&page={}", encoded_q, next_page);
let elapsed_ms = start.elapsed().as_millis();
let mut msg = Vec::new();
if query_invalid {
msg.push("invalid query");
}
if list_error {
msg.push("list error");
}
if filtered_total == 0 {
msg.push("no results");
}
if page == 0 {
html! {
div id="search-results" {
p class="search-meta" {
"Filtered " (filtered_total) "/" (grand_total) " items in " (elapsed_ms) "ms"
@if !msg.is_empty() {
span style="color:var(--orange)" {
(format!("\u{00A0}\u{00A0}({})", msg.join(", ")))
}
}
}
div class="result-grid" {
@for (source, key) in &page_items {
(result_item(source, key))
}
@if has_more {
div
class="result-sentinel"
hx-get=(next_url)
hx-trigger="revealed"
hx-swap="outerHTML"
hx-target="this"
{}
}
}
}
}
} else {
html! {
@for (source, key) in &page_items {
(result_item(source, key))
}
@if has_more {
div
class="result-sentinel"
hx-get=(next_url)
hx-trigger="revealed"
hx-swap="outerHTML"
hx-target="this"
{}
}
}
}
}
fn result_item(source: &str, key: &str) -> Markup {
let enc_source: String = url::form_urlencoded::byte_serialize(source.as_bytes()).collect();
let enc_key: String = url::form_urlencoded::byte_serialize(key.as_bytes()).collect();
let enc_path: String =
url::form_urlencoded::byte_serialize("$.pdf.pages[0]".as_bytes()).collect();
let thumb_url =
format!("{PILE_PREFIX}/api/field?source={enc_source}&key={enc_key}&path={enc_path}");
let item_url =
format!("{PILE_PREFIX}/api/item?source={enc_source}&key={enc_key}&download=false");
html! {
div class="result-item" {
div class="result-item-thumb"
data-preview=(thumb_url)
onmouseenter="showPreview(this)"
onmouseleave="hidePreview()"
{
img src=(thumb_url) alt="" onerror="this.style.visibility='hidden'" {}
}
div class="result-item-info" {
span class="result-item-key"
data-key=(key)
onclick="navigator.clipboard.writeText(this.dataset.key)"
title="Click to copy"
{ (key) }
a class="result-item-link" href=(item_url) target="_blank" { "item" }
}
}
}
}
pub static CSS_PILE: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| format!("/css/{}/main.css", *CACHE_BUST_STR),
StaticAsset {
bytes: grass::include!("crates/service/service-pile/css/main.scss").as_bytes(),
mime: mime::TEXT_CSS,
ttl: StaticAsset::DEFAULT_TTL,
},
);
fn build_server() -> ServableRouter {
ServableRouter::new()
.with_404(&pages::NOT_FOUND)
.add_page("/", &pages::INDEX)
//
.add_page_with_route(&CSS_PILE)
}
#[test]
#[expect(clippy::unwrap_used)]
fn server_builds_without_panic() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let _server = build_server();
});
}

View File

@@ -9,18 +9,16 @@ workspace = true
[dependencies] [dependencies]
libservice = { workspace = true } libservice = { workspace = true }
macro-sass = { workspace = true } service-assets = { 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 }
emojis = { workspace = true } emojis = { workspace = true }
strum = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
parking_lot = { workspace = true } parking_lot = { workspace = true }
lazy_static = { workspace = true } lazy_static = { workspace = true }
@@ -28,3 +26,6 @@ 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 }
mime = { workspace = true }

Binary file not shown.

View File

@@ -3,36 +3,6 @@
@import "images"; @import "images";
@import "special"; @import "special";
@import "fontawesome/fontawesome";
@import "fontawesome/brands";
@import "fontawesome/regular";
@import "fontawesome/solid";
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Bold.woff2") format("woff2");
font-weight: bold;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Light.woff2") format("woff2");
font-weight: light;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Medium.woff2") format("woff2");
font-weight: medium;
}
@font-face {
font-family: "Fira";
src: url("/assets/fonts/FiraCode-Regular.woff2") format("woff2");
font-weight: normal;
}
:root { :root {
// Misc colors // Misc colors
--bgColor: #121212; --bgColor: #121212;

View File

@@ -1,10 +1,8 @@
use std::str::FromStr;
use markdown_it::parser::inline::{InlineRule, InlineState}; use markdown_it::parser::inline::{InlineRule, InlineState};
use markdown_it::{Node, NodeValue, Renderer}; use markdown_it::{Node, NodeValue, Renderer};
use maud::Render; use maud::Render;
use service_assets::components::fa::FAIcon;
use crate::components::fa::FAIcon; use std::str::FromStr;
#[derive(Debug)] #[derive(Debug)]
pub struct InlineEmote(String); pub struct InlineEmote(String);

View File

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

View File

@@ -1,4 +1,2 @@
pub mod fa;
pub mod mangle; pub mod mangle;
pub mod md; pub mod md;
pub mod misc;

View File

@@ -31,4 +31,4 @@ A snippet of the [_Endless Sky_][es] map is below.
<br/> <br/>
<img class="img-placeholder" src="/assets/img/betalupi.png?t=maxdim(50,50)" data-large="/assets/img/betalupi.png" style="width:100%;height=10rem;"></img> <img class="img-placeholder" src="/static/img/betalupi.png?t=maxdim(50,50)" data-large="/static/img/betalupi.png" style="width:100%;height=10rem;"></img>

View File

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

View File

@@ -1,22 +1,24 @@
use std::{ use std::{
pin::Pin, pin::Pin,
sync::Arc, sync::{Arc, LazyLock},
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 service_assets::{
assets::{CSS_FIRA, CSS_FONTAWESOME, IMG_ICON},
components::misc::FarLink,
};
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}, pages::{LAZY_IMAGE_JS, backlinks, footer},
misc::FarLink, routes::MAIN_CSS,
},
pages::page_wrapper,
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -113,11 +115,7 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
return Ok(res); return Ok(res);
} }
fn build_list_for_group( fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &RenderContext) -> Markup {
handouts: &[HandoutEntry],
group: &str,
req_ctx: &RequestContext,
) -> Markup {
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile; let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
if mobile { if mobile {
@@ -178,7 +176,7 @@ fn build_list_for_group(
// MARK: page // MARK: page
// //
pub fn handouts() -> Page { pub static HANDOUTS: LazyLock<HtmlPage> = LazyLock::new(|| {
let md = Markdown::parse(include_str!("handouts.md")); let md = Markdown::parse(include_str!("handouts.md"));
let index = CachedRequest::new( let index = CachedRequest::new(
@@ -192,19 +190,31 @@ pub fn handouts() -> Page {
let mut meta = meta_from_markdown(&md).unwrap().unwrap(); let mut meta = meta_from_markdown(&md).unwrap().unwrap();
if meta.image.is_none() { if meta.image.is_none() {
meta.image = Some("/assets/img/icon.png".to_owned()); meta.image = Some(IMG_ICON.route_at("/assets"));
} }
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
Page { HtmlPage::default()
meta, .with_style_linked(MAIN_CSS.route())
html_ttl: Some(TimeDelta::seconds(300)), .with_style_linked(CSS_FIRA.route_at("/assets"))
immutable: false, .with_style_linked(CSS_FONTAWESOME.route_at("/assets"))
.with_script_inline(LAZY_IMAGE_JS)
generate_html: Box::new(move |page, ctx| { .with_meta(meta)
let html = html.clone(); // TODO: find a way to not clone here .with_render(move |page, ctx| {
let html = html.clone();
let index = index.clone(); let index = index.clone();
render(html, index, page, ctx)
})
.with_ttl(Some(TimeDelta::seconds(300)))
});
fn render<'a>(
html: Markup,
index: Arc<CachedRequest<Result<Vec<HandoutEntry>, reqwest::Error>>>,
_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;
@@ -234,8 +244,9 @@ pub fn handouts() -> Page {
Err(_) => fallback, Err(_) => fallback,
}; };
let inner = html! { html! {
@if let Some(backlinks) = backlinks(page, ctx) { div class="wrapper" style="margin-top:3ex;" {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks) (backlinks)
} }
@@ -260,10 +271,8 @@ pub fn handouts() -> Page {
))) )))
(advanced) (advanced)
br {} br {}
}; (footer())
page_wrapper(&page.meta, inner, true).await
})
}),
} }
}
})
} }

View File

@@ -5,7 +5,7 @@ Also see [what's a "betalupi?"](/whats-a-betalupi)
- [Handouts](/handouts): Math circle lessons I've written - [Handouts](/handouts): Math circle lessons I've written
- [Links](/links): Interesting parts of the internet - [Links](/links): Interesting parts of the internet
<hr style="margin-top: 8rem; margin-bottom: 8rem"/> <hr style="margin-top: 5rem; margin-bottom: 5rem"/>
## Projects ## Projects

View File

@@ -1,37 +1,49 @@
use maud::html; use maud::{Markup, html};
use page::page::{Page, PageMetadata}; use servable::{HtmlPage, PageMetadata, RenderContext};
use service_assets::{
assets::{CSS_FIRA, CSS_FONTAWESOME, IMG_ICON},
components::{fa::FAIcon, misc::FarLink},
};
use std::{pin::Pin, sync::LazyLock};
use crate::{ use crate::{
components::{ components::{
fa::FAIcon,
mangle::{MangledBetaEmail, MangledGoogleEmail}, mangle::{MangledBetaEmail, MangledGoogleEmail},
md::Markdown, md::Markdown,
misc::FarLink,
}, },
pages::page_wrapper, pages::{LAZY_IMAGE_JS, footer},
routes::{IMG_COVER_SMALL, MAIN_CSS},
}; };
pub fn index() -> Page { pub static INDEX: LazyLock<HtmlPage> = LazyLock::new(|| {
Page { HtmlPage::default()
meta: PageMetadata { .with_style_linked(MAIN_CSS.route())
.with_style_linked(CSS_FIRA.route_at("/assets"))
.with_style_linked(CSS_FONTAWESOME.route_at("/assets"))
.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("/assets/img/icon.png".to_owned()), image: Some(IMG_ICON.route_at("/assets")),
backlinks: Some(false), })
}, .with_render(render)
});
generate_html: Box::new(move |page, _ctx| { fn render<'a>(
_page: &'a HtmlPage,
_ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
Box::pin(async { Box::pin(async {
let inner = html! { html! {
div class="wrapper" style="margin-top:3ex;" {
h2 id="about" { "About" } h2 id="about" { "About" }
div { div {
img img
class="img-placeholder" class="img-placeholder"
src="/assets/img/cover-small.jpg?t=maxdim(20,20)" src=(format!("{}?t=maxdim(20,20)", IMG_COVER_SMALL.route()))
data-large="/assets/img/cover-small.jpg" data-large=(IMG_COVER_SMALL.route())
style="image-rendering:pixelated;float:left;margin:10px;display:block;width:25%;" style="image-rendering:pixelated;float:left;margin:10px;display:block;width:25%;"
{} {}
@@ -72,12 +84,9 @@ pub fn index() -> Page {
} }
(Markdown(include_str!("index.md"))) (Markdown(include_str!("index.md")))
};
page_wrapper(&page.meta, inner, true).await (footer())
})
}),
..Default::default()
} }
}
})
} }

View File

@@ -80,6 +80,7 @@ This is a heavily opinionated bookmarks toolbar.
- [tokei](https://github.com/XAMPPRocky/tokei): count lines of code - [tokei](https://github.com/XAMPPRocky/tokei): count lines of code
- [delta](https://github.com/dandavison/delta): pretty pager for diffs - [delta](https://github.com/dandavison/delta): pretty pager for diffs
- [dust](https://github.com/dandavison/delta): `du`, but better - [dust](https://github.com/dandavison/delta): `du`, but better
- [bandwhich](https://github.com/imsnif/bandwhich): network stats
<br></br> <br></br>

View File

@@ -1,54 +1,29 @@
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{DOCTYPE, Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use page::page::{Page, PageMetadata}; use reqwest::StatusCode;
use std::pin::Pin; use servable::{HtmlPage, PageMetadata, RenderContext};
use service_assets::{
assets::{CSS_FIRA, CSS_FONTAWESOME, IMG_ICON},
components::{fa::FAIcon, misc::FarLink},
};
use std::sync::LazyLock;
use crate::components::{ use crate::{
md::{Markdown, backlinks, meta_from_markdown}, components::md::{Markdown, meta_from_markdown},
misc::FarLink, routes::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;
pub fn links() -> Page {
/*
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>) -> Page { fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> HtmlPage {
let md: String = md.into(); let md: String = md.into();
let md = Markdown::parse(&md); let md = Markdown::parse(&md);
@@ -65,59 +40,37 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
Page { HtmlPage::default()
meta, .with_script_inline(LAZY_IMAGE_JS)
immutable: true, .with_style_linked(MAIN_CSS.route())
.with_style_linked(CSS_FIRA.route_at("/assets"))
html_ttl: Some(TimeDelta::days(1)), .with_style_linked(CSS_FONTAWESOME.route_at("/assets"))
generate_html: Box::new(move |page, ctx| { .with_meta(meta)
.with_render(move |_page, ctx| {
let html = html.clone(); let html = html.clone();
Box::pin(async move { Box::pin(async move {
let inner = html! { html! {
@if let Some(backlinks) = backlinks(page, ctx) { div class="wrapper" style="margin-top:3ex;" {
@if let Some(backlinks) = backlinks(ctx) {
(backlinks) (backlinks)
} }
(html) (html)
};
page_wrapper(&page.meta, inner, true).await (footer())
})
}),
} }
}
})
})
.with_ttl(Some(TimeDelta::days(1)))
} }
// //
// MARK: wrapper // MARK: components
// //
pub fn page_wrapper<'a>( const LAZY_IMAGE_JS: &str = "
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');
@@ -134,42 +87,133 @@ pub fn page_wrapper<'a>(
}; };
}) })
} }
")) ";
}
/*
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
} }
body { last.map(|last| {
main{ html! {
div class="wrapper" style=( div {
// for 404 page. Margin makes it scroll. @for (url, text) in backlinks {
match footer { a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
true => "margin-top:3ex;", "/"
false =>""
} }
) {
(inner)
@if footer { span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
footer { }
hr class = "footline" {} }
})
}
pub fn footer() -> Markup {
html!(
footer style="margin-top:10rem;" {
hr class = "footline";
div class = "footContainer" { div class = "footContainer" {
p { p {
"This site was built by hand 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 static 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_at("/assets")))
});
pub static BETALUPI: LazyLock<HtmlPage> = LazyLock::new(|| {
page_from_markdown(
include_str!("betalupi.md"),
Some(IMG_ICON.route_at("/assets")),
)
});
pub static HTWAH_TYPESETTING: LazyLock<HtmlPage> = LazyLock::new(|| {
page_from_markdown(
include_str!("htwah-typesetting.md"),
Some(IMG_ICON.route_at("/assets")),
)
});
pub static NOT_FOUND: LazyLock<HtmlPage> = LazyLock::new(|| {
HtmlPage::default()
.with_style_linked(MAIN_CSS.route())
.with_style_linked(CSS_FIRA.route_at("/assets"))
.with_style_linked(CSS_FONTAWESOME.route_at("/assets"))
.with_meta(PageMetadata {
title: "Page not found".into(),
author: None,
description: None,
image: Some(IMG_ICON.route_at("/assets")),
})
.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

@@ -1,32 +0,0 @@
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,203 +1,139 @@
use axum::Router; use axum::Router;
use macro_sass::sass; use servable::{CACHE_BUST_STR, Redirect, ServableRouter, ServableWithRoute, StaticAsset};
use page::{PageServer, asset::StaticAsset, redirect::Redirect}; use tower_http::compression::{CompressionLayer, DefaultPredicate};
use std::sync::Arc;
use toolbox::mime::MimeType;
use crate::pages; use crate::pages;
pub(super) fn router() -> Router<()> { pub(super) fn router() -> Router<()> {
build_server().into_router() let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
build_server().into_router().layer(compression)
} }
fn build_server() -> Arc<PageServer> { pub static MAIN_CSS: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
let server = PageServer::new(); || format!("/static/{}/css/main.css", *CACHE_BUST_STR),
#[expect(clippy::unwrap_used)]
server
.with_404(pages::notfound())
.add_page("/", pages::index())
.add_page("/links", pages::links())
.add_page("/whats-a-betalupi", pages::betalupi())
.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: sass!("css/main.scss").as_bytes(), bytes: grass::include!("crates/service/service-webpage/css/main.scss").as_bytes(),
mime: MimeType::Css, mime: mime::TEXT_CSS,
ttl: StaticAsset::DEFAULT_TTL,
}, },
) );
.add_page(
"/assets/img/cover-small.jpg", pub static IMG_COVER_SMALL: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/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: mime::IMAGE_JPEG,
ttl: StaticAsset::DEFAULT_TTL,
}, },
) );
.add_page(
"/assets/img/betalupi.png", pub static IMG_BETALUPI: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/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: mime::IMAGE_PNG,
ttl: StaticAsset::DEFAULT_TTL,
}, },
) );
.add_page(
"/assets/img/icon.png", //
// MARK: htwah
//
pub static HTWAH_DEFINITIONS: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/htwah/definitions.pdf".into(),
StaticAsset { StaticAsset {
bytes: include_bytes!("../../assets/images/icon.png"), bytes: include_bytes!("../../assets/htwah/definitions.pdf"),
mime: MimeType::Png, mime: mime::APPLICATION_PDF,
ttl: StaticAsset::DEFAULT_TTL,
}, },
) );
pub static HTWAH_NUMBERING: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/htwah/numbering.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/numbering.pdf"),
mime: mime::APPLICATION_PDF,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SOLS_A: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/htwah/sols-a.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/sols-a.pdf"),
mime: mime::APPLICATION_PDF,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SOLS_B: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/htwah/sols-b.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/sols-b.pdf"),
mime: mime::APPLICATION_PDF,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SPACING_A: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/htwah/spacing-a.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/spacing-a.pdf"),
mime: mime::APPLICATION_PDF,
ttl: StaticAsset::DEFAULT_TTL,
},
);
pub static HTWAH_SPACING_B: ServableWithRoute<StaticAsset> = ServableWithRoute::new(
|| "/static/htwah/spacing-b.pdf".into(),
StaticAsset {
bytes: include_bytes!("../../assets/htwah/spacing-b.pdf"),
mime: mime::APPLICATION_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)
// //
// MARK: fonts .add_page_with_route(&MAIN_CSS)
// .add_page_with_route(&IMG_COVER_SMALL)
.add_page( .add_page_with_route(&IMG_BETALUPI)
"/assets/fonts/FiraCode-Bold.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Bold.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-Light.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Light.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-Medium.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Medium.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-Regular.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-Regular.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-SemiBold.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-SemiBold.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/FiraCode-VF.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fira/FiraCode-VF.woff2"),
mime: MimeType::Woff2,
},
)
//
// MARK: icons
//
.add_page(
"/assets/fonts/fa/fa-brands-400.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/fa/fa-regular-400.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/fa/fa-solid-900.woff2",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.woff2"),
mime: MimeType::Woff2,
},
)
.add_page(
"/assets/fonts/fa/fa-brands-400.ttf",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-brands-400.ttf"),
mime: MimeType::Ttf,
},
)
.add_page(
"/assets/fonts/fa/fa-regular-400.ttf",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-regular-400.ttf"),
mime: MimeType::Ttf,
},
)
.add_page(
"/assets/fonts/fa/fa-solid-900.ttf",
StaticAsset {
bytes: include_bytes!("../../assets/fonts/fa/fa-solid-900.ttf"),
mime: MimeType::Ttf,
},
)
// //
// MARK: htwah // MARK: htwah
// //
.add_page( .add_page_with_route(&HTWAH_DEFINITIONS)
"/assets/htwah/definitions.pdf", .add_page_with_route(&HTWAH_NUMBERING)
StaticAsset { .add_page_with_route(&HTWAH_SOLS_A)
bytes: include_bytes!("../../assets/htwah/definitions.pdf"), .add_page_with_route(&HTWAH_SOLS_B)
mime: MimeType::Pdf, .add_page_with_route(&HTWAH_SPACING_A)
}, .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]
#[expect(clippy::unwrap_used)]
fn server_builds_without_panic() { fn server_builds_without_panic() {
tokio::runtime::Builder::new_current_thread() tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
// Needs tokio context let _server = build_server();
let _server = build_server().into_router();
}); });
} }

View File

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