Compare commits

..

1 Commits

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

374
Cargo.lock generated
View File

@@ -394,12 +394,6 @@ version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "byteorder-lite" name = "byteorder-lite"
version = "0.1.0" version = "0.1.0"
@@ -699,29 +693,6 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.108",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.5"
@@ -766,26 +737,6 @@ dependencies = [
"syn 2.0.108", "syn 2.0.108",
] ]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]] [[package]]
name = "diff" name = "diff"
version = "0.1.13" version = "0.1.13"
@@ -861,27 +812,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dtoa"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]] [[package]]
name = "either" name = "either"
version = "1.15.0" version = "1.15.0"
@@ -953,16 +883,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "exr" name = "exr"
version = "1.73.0" version = "1.73.0"
@@ -989,12 +909,6 @@ dependencies = [
"regex-syntax", "regex-syntax",
] ]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "fax" name = "fax"
version = "0.2.6" version = "0.2.6"
@@ -1047,6 +961,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -1066,31 +986,6 @@ dependencies = [
"syn 2.0.108", "syn 2.0.108",
] ]
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -1107,17 +1002,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.31" version = "0.3.31"
@@ -1153,7 +1037,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro", "futures-macro",
@@ -1165,15 +1048,6 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.9" version = "0.14.9"
@@ -1184,15 +1058,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width 0.2.2",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.16" version = "0.2.16"
@@ -1306,6 +1171,11 @@ name = "hashbrown"
version = "0.16.0" version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]] [[package]]
name = "heck" name = "heck"
@@ -1342,17 +1212,6 @@ dependencies = [
"utf8-width", "utf8-width",
] ]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever",
"match_token",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.3.1" version = "1.3.1"
@@ -1822,12 +1681,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
@@ -1874,18 +1727,21 @@ dependencies = [
"imgref", "imgref",
] ]
[[package]]
name = "lru"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f"
dependencies = [
"hashbrown 0.16.0",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "macro-sass" name = "macro-sass"
version = "0.0.1" version = "0.0.1"
@@ -1904,7 +1760,7 @@ dependencies = [
"argparse", "argparse",
"const_format", "const_format",
"derivative", "derivative",
"derive_more 0.99.20", "derive_more",
"downcast-rs", "downcast-rs",
"entities", "entities",
"html-escape", "html-escape",
@@ -1918,28 +1774,6 @@ dependencies = [
"unicode-general-category", "unicode-general-category",
] ]
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -2251,26 +2085,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "ormc-scrape"
version = "0.0.1"
dependencies = [
"anyhow",
"axum",
"base64",
"clap",
"futures",
"reqwest",
"scraper",
"serde",
"serde_json",
"tempfile",
"tokio",
"toolbox",
"tracing",
"url",
]
[[package]] [[package]]
name = "owo-colors" name = "owo-colors"
version = "4.2.3" version = "4.2.3"
@@ -2283,13 +2097,15 @@ version = "0.0.1"
dependencies = [ dependencies = [
"axum", "axum",
"chrono", "chrono",
"libservice",
"lru",
"maud", "maud",
"parking_lot",
"pixel-transform", "pixel-transform",
"serde", "serde",
"serde_urlencoded",
"tokio", "tokio",
"toolbox", "toolbox",
"tower", "tower-http",
"tracing", "tracing",
] ]
@@ -2347,16 +2163,6 @@ dependencies = [
"phf_shared 0.13.1", "phf_shared 0.13.1",
] ]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
]
[[package]] [[package]]
name = "phf_generator" name = "phf_generator"
version = "0.11.3" version = "0.11.3"
@@ -2477,12 +2283,6 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "pretty_assertions" name = "pretty_assertions"
version = "1.4.1" version = "1.4.1"
@@ -3029,19 +2829,6 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.34" version = "0.23.34"
@@ -3110,40 +2897,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "selectors"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6"
dependencies = [
"bitflags",
"cssparser",
"derive_more 2.0.1",
"fxhash",
"log",
"new_debug_unreachable",
"phf 0.11.3",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.27" version = "1.0.27"
@@ -3259,19 +3012,9 @@ dependencies = [
"tokio", "tokio",
"toml 0.9.8", "toml 0.9.8",
"toolbox", "toolbox",
"tower-http",
"tracing", "tracing",
] ]
[[package]]
name = "servo_arc"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -3387,31 +3130,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@@ -3611,30 +3329,6 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]] [[package]]
name = "term" name = "term"
version = "0.7.0" version = "0.7.0"
@@ -4220,12 +3914,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf8-width" name = "utf8-width"
version = "0.1.7" version = "0.1.7"
@@ -4446,18 +4134,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]] [[package]]
name = "webpage" name = "webpage"
version = "0.0.1" version = "0.0.1"

View File

@@ -80,8 +80,6 @@ service-webpage = { path = "crates/service/service-webpage" }
# #
axum = { version = "0.8.6", features = ["macros", "multipart"] } axum = { version = "0.8.6", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace", "compression-full"] } tower-http = { version = "0.6.6", features = ["trace", "compression-full"] }
tower = { version = "0.5.2" }
serde_urlencoded = { version = "0.7.1" }
utoipa = "5.4.0" utoipa = "5.4.0"
utoipa-swagger-ui = { version = "9.0.2", features = [ utoipa-swagger-ui = { version = "9.0.2", features = [
"axum", "axum",
@@ -95,7 +93,6 @@ emojis = "0.8.0"
reqwest = { version = "0.12.24", default-features = false, features = [ reqwest = { version = "0.12.24", default-features = false, features = [
"http2", "http2",
"rustls-tls", "rustls-tls",
"rustls-tls-webpki-roots", # Need to recompile to update
"cookies", "cookies",
"gzip", "gzip",
"stream", "stream",
@@ -147,9 +144,6 @@ lru = "0.16.2"
parking_lot = "0.12.5" parking_lot = "0.12.5"
lazy_static = "1.5.0" lazy_static = "1.5.0"
image = "0.25.8" image = "0.25.8"
scraper = "0.24.0"
futures = "0.3.31"
tempfile = "3.23.0"
# md_* test utilities # md_* test utilities
prettydiff = "0.9.0" prettydiff = "0.9.0"

View File

@@ -1,10 +1,9 @@
[utoipa]: https://docs.rs/utoipa/latest/utoipa/ [`utoipa`]: https://docs.rs/utoipa/latest/utoipa/
[axum]: https://docs.rs/axum/latest/axum/ [`axum`]: https://docs.rs/axum/latest/axum/
[betalupi.com]: https://betalupi.com
# Mark's webpage # 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... This is the source code behind [betalupi.com](https://betalupi.com), featuring a very efficient mini web framework written from scratch in Rust. It uses...
- [Axum](https://github.com/tokio-rs/axum) as an http server - [Axum](https://github.com/tokio-rs/axum) as an http server
- [Maud](https://maud.lambda.xyz/) for html templates - [Maud](https://maud.lambda.xyz/) for html templates
- [Grass](https://github.com/connorskees/grass) to parse and compile [sass](https://sass-lang.com/) - [Grass](https://github.com/connorskees/grass) to parse and compile [sass](https://sass-lang.com/)
@@ -13,11 +12,11 @@ This is the source code behind [betalupi.com], featuring a very efficient mini w
## Overview & Arch: ## Overview & Arch:
- [`bin/webpage`](./crates/bin/webpage/): Simple cli that starts `service-webpage` - [`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. \ - [`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. 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. - [`lib/page`](./crates/lib/page): Provides [PageServer], which builds an [`axum`] router that provides a caching and headers for resources served through http.
- Also provides `Servable`, which is a trait for any resource that may be served. - Also provides [Servable], which is a trait for any resource that may be served.
- the `Page` servable serves html generated by a closure. - the [Page] servable serves html generated by a closure.
- the `StaticAsset` servable serves static assets (css, images, misc files), and provides transformation parameters for image assets (via [`pixel-transform`](./crates/lib/pixel-transform)). - the [StaticAsset] servable serves static assets (css, images, misc files), and provides transformation utilties for image assets (via [`pixel-transform`](./crates/lib/pixel-transform)).
- [`service/service-webpage`](./crates/service/service-webpage): A `Service` that runs a `PageServer` that provides the content on [betalupi.com] - [`service/service-webpage`](./crates/service/service-webpage): A `Service` that runs a `PageServer` that provides the content on [betalupi.com](https://betalupi.com)

View File

@@ -9,6 +9,7 @@ workspace = true
[dependencies] [dependencies]
toolbox = { workspace = true } toolbox = { workspace = true }
libservice = { workspace = true }
pixel-transform = { workspace = true } pixel-transform = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
@@ -16,6 +17,7 @@ tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
maud = { workspace = true } maud = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tower = { workspace = true } lru = { workspace = true }
serde_urlencoded = { workspace = true } tower-http = { workspace = true }

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ use std::{pin::Pin, str::FromStr};
use toolbox::mime::MimeType; use toolbox::mime::MimeType;
use tracing::{error, trace}; use tracing::{error, trace};
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable}; use crate::{Rendered, RenderedBody, RequestContext, Servable};
pub struct StaticAsset { pub struct StaticAsset {
pub bytes: &'static [u8], pub bytes: &'static [u8],
@@ -13,69 +13,10 @@ pub struct StaticAsset {
} }
impl Servable for StaticAsset { impl Servable for StaticAsset {
fn head<'a>(
&'a self,
ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async {
let ttl = Some(TimeDelta::days(30));
let is_image = TransformerChain::mime_is_image(&self.mime);
let transform = match (is_image, ctx.query.get("t")) {
(false, _) | (_, None) => None,
(true, Some(x)) => match TransformerChain::from_str(x) {
Ok(x) => Some(x),
Err(_err) => {
return Rendered {
code: StatusCode::BAD_REQUEST,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: None,
};
}
},
};
match transform {
Some(transform) => {
return Rendered {
code: StatusCode::OK,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(
transform
.output_mime(&self.mime)
.unwrap_or(self.mime.clone()),
),
};
}
None => {
return Rendered {
code: StatusCode::OK,
body: (),
ttl,
immutable: true,
headers: HeaderMap::new(),
mime: Some(self.mime.clone()),
};
}
}
})
}
fn render<'a>( fn render<'a>(
&'a self, &'a self,
ctx: &'a RenderContext, ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
Box::pin(async { Box::pin(async {
let ttl = Some(TimeDelta::days(30)); let ttl = Some(TimeDelta::days(30));

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,324 @@
use axum::{
Router,
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use chrono::{DateTime, TimeDelta, Utc};
use libservice::ServiceConnectInfo;
use lru::LruCache;
use maud::Markup;
use parking_lot::Mutex;
use std::{
collections::{BTreeMap, HashMap},
num::NonZero,
pin::Pin,
sync::Arc,
time::Instant,
};
use toolbox::mime::MimeType;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use tracing::trace;
use crate::{ClientInfo, RequestContext};
#[derive(Clone)]
pub enum RenderedBody {
Markup(Markup),
Static(&'static [u8]),
Bytes(Vec<u8>),
String(String),
Empty,
}
#[derive(Clone)]
pub struct Rendered {
pub code: StatusCode,
pub headers: HeaderMap,
pub body: RenderedBody,
pub mime: Option<MimeType>,
/// How long to cache this response.
/// If none, don't cache.
pub ttl: Option<TimeDelta>,
pub immutable: bool,
}
pub trait Servable: Send + Sync {
fn render<'a>(
&'a self,
ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
}
pub struct Default404 {}
impl Servable for Default404 {
fn render<'a>(
&'a self,
_ctx: &'a RequestContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
Box::pin(async {
return Rendered {
code: StatusCode::NOT_FOUND,
body: RenderedBody::String("page not found".into()),
ttl: Some(TimeDelta::days(1)),
immutable: true,
headers: HeaderMap::new(),
mime: Some(MimeType::Html),
};
})
}
}
//
// MARK: server
//
pub struct PageServer {
/// If true, expired pages will be rerendered before being sent to the user.
/// If false, requests never trigger rerenders. We rely on the rerender task.
///
/// If true, we deliver fresher pages but delay responses.
/// TODO: replace this with a smarter rendering strategy?
never_rerender_on_request: bool,
/// Map of `{ route: page }`
pages: Mutex<HashMap<String, Arc<dyn Servable>>>,
notfound: Mutex<Arc<dyn Servable>>,
/// Map of `{ route: (page data, expire time) }`
///
/// We use an LruCache for bounded memory usage.
page_cache: Mutex<LruCache<RequestContext, (Rendered, DateTime<Utc>)>>,
}
impl PageServer {
pub fn new() -> Arc<Self> {
#[expect(clippy::unwrap_used)]
let cache_size = NonZero::new(128).unwrap();
Arc::new(Self {
pages: Mutex::new(HashMap::new()),
page_cache: Mutex::new(LruCache::new(cache_size)),
never_rerender_on_request: true,
notfound: Mutex::new(Arc::new(Default404 {})),
})
}
/// Set this server's "not found" page
pub fn with_404<S: Servable + 'static>(&self, page: S) -> &Self {
*self.notfound.lock() = Arc::new(page);
self
}
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
#[expect(clippy::expect_used)]
let route = route
.into()
.strip_prefix("/")
.expect("route must start with /")
.to_owned();
self.pages.lock().insert(route, Arc::new(page));
self
}
/// Re-render the page at `route`, regardless of cache state.
/// Does nothing if there is no page at `route`.
///
/// Returns the rendered page's content.
async fn render_page(
&self,
reason: &'static str,
route: &str,
ctx: RequestContext,
) -> (Rendered, Option<DateTime<Utc>>) {
let now = Utc::now();
let start = Instant::now();
let page = match self.pages.lock().get(route) {
Some(x) => x.clone(),
None => self.notfound.lock().clone(),
};
trace!(
message = "Rendering page",
route = route.to_owned(),
reason,
lock_time_ms = start.elapsed().as_millis()
);
let rendered = page.render(&ctx).await;
let mut expires = None;
if let Some(ttl) = rendered.ttl {
expires = Some(now + ttl);
self.page_cache
.lock()
.put(ctx, (rendered.clone(), now + ttl));
}
let elapsed = start.elapsed().as_millis();
trace!(
message = "Rendered page",
route = route.to_owned(),
reason,
time_ms = elapsed
);
return (rendered, expires);
}
async fn handler(
Path(route): Path<String>,
Query(query): Query<BTreeMap<String, String>>,
State(state): State<Arc<Self>>,
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
headers: HeaderMap,
) -> Response {
let start = Instant::now();
let client_info = ClientInfo::from_headers(&headers);
let ua = headers
.get("user-agent")
.and_then(|x| x.to_str().ok())
.unwrap_or("");
trace!(
message = "Serving route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
// Normalize url with redirect
if route.ends_with('/') || route.contains("//") || route.starts_with('/') {
let mut new_route = route.clone();
while new_route.contains("//") {
new_route = new_route.replace("//", "/");
}
let new_route = new_route.trim_matches('/');
trace!(
message = "Redirecting",
route,
new_route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
let mut headers = HeaderMap::with_capacity(2);
let new_route = match HeaderValue::from_str(&format!("/{new_route}")) {
Ok(x) => x,
Err(_) => {
// Be extra careful, this is user-provided data
return StatusCode::BAD_REQUEST.into_response();
}
};
headers.append(header::LOCATION, new_route);
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
return (StatusCode::PERMANENT_REDIRECT, headers).into_response();
}
let ctx = RequestContext {
client_info,
route: format!("/{route}"),
query,
};
let now = Utc::now();
let mut html_expires = None;
let mut cached = true;
// Get from cache, if available
if let Some((html, expires)) = state.page_cache.lock().get(&ctx)
&& (*expires > now || state.never_rerender_on_request)
{
html_expires = Some((html.clone(), Some(*expires)));
};
if html_expires.is_none() {
cached = false;
html_expires = Some(state.render_page("request", &route, ctx).await);
}
#[expect(clippy::unwrap_used)]
let (mut html, expires) = html_expires.unwrap();
if !html.headers.contains_key(header::CACHE_CONTROL) {
let max_age = match expires {
Some(expires) => (expires - now).num_seconds().max(1),
None => 1,
};
let mut value = String::new();
if html.immutable {
value.push_str("immutable, ");
}
value.push_str("public, ");
value.push_str(&format!("max-age={}, ", max_age));
#[expect(clippy::unwrap_used)]
html.headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
);
}
if !html.headers.contains_key("Accept-CH") {
html.headers
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
}
if let Some(mime) = &html.mime {
#[expect(clippy::unwrap_used)]
html.headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_str(&mime.to_string()).unwrap(),
);
}
trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos()
);
return match html.body {
RenderedBody::Markup(markup) => (html.code, html.headers, markup.0).into_response(),
RenderedBody::Static(data) => (html.code, html.headers, data).into_response(),
RenderedBody::Bytes(data) => (html.code, html.headers, data).into_response(),
RenderedBody::String(s) => (html.code, html.headers, s).into_response(),
RenderedBody::Empty => (html.code, html.headers).into_response(),
};
}
pub fn into_router(self: Arc<Self>) -> Router<()> {
let compression: CompressionLayer = CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
Router::new()
.route(
"/",
get(|state, query, conn, headers| async {
Self::handler(Path(String::new()), query, state, conn, headers).await
}),
)
.route("/{*path}", get(Self::handler))
.layer(compression)
.with_state(self)
}
}

View File

@@ -38,20 +38,6 @@ impl TransformerChain {
return image; return image;
} }
pub fn output_mime(&self, input_mime: &MimeType) -> Option<MimeType> {
let mime = self
.steps
.last()
.and_then(|x| match x {
TransformerEnum::Format { format } => Some(MimeType::from(format.to_mime_type())),
_ => None,
})
.unwrap_or(input_mime.clone());
let fmt = ImageFormat::from_mime_type(mime.to_string());
fmt.map(|_| mime)
}
pub fn transform_bytes( pub fn transform_bytes(
&self, &self,
image_bytes: &[u8], image_bytes: &[u8],

View File

@@ -251,23 +251,6 @@ impl From<&MimeType> for String {
// MARK: fromstr // MARK: fromstr
// //
impl MimeType {
/// Parse a mimetype from a string that may contain
/// whitespace or ";" parameters.
///
/// Parameters are discarded, write your own parser if you need them.
pub fn from_header(s: &str) -> Result<Self, <Self as FromStr>::Err> {
let s = s.trim();
let semi = s.find(';').unwrap_or(s.len());
let space = s.find(' ').unwrap_or(s.len());
let limit = semi.min(space);
let s = &s[0..limit];
let s = s.trim();
return Self::from_str(s);
}
}
impl FromStr for MimeType { impl FromStr for MimeType {
type Err = std::convert::Infallible; type Err = std::convert::Infallible;
@@ -704,108 +687,4 @@ impl MimeType {
Self::Xul => Some("xul"), Self::Xul => Some("xul"),
} }
} }
//
// MARK: is_text
//
/// Returns true if this MIME type is always plain text.
pub fn is_text(&self) -> bool {
match self {
// Text types
Self::Text => true,
Self::Css => true,
Self::Csv => true,
Self::Html => true,
Self::Javascript => true,
Self::Json => true,
Self::JsonLd => true,
Self::Xml => true,
Self::Svg => true,
Self::Ics => true,
Self::Xhtml => true,
// Script types
Self::Csh => true,
Self::Php => true,
Self::Sh => true,
// All other types are not plain text
Self::Other(_) => false,
Self::Blob => false,
// Audio
Self::Aac => false,
Self::Flac => false,
Self::Midi => false,
Self::Mp3 => false,
Self::Oga => false,
Self::Opus => false,
Self::Wav => false,
Self::Weba => false,
// Video
Self::Avi => false,
Self::Mp4 => false,
Self::Mpeg => false,
Self::Ogv => false,
Self::Ts => false,
Self::WebmVideo => false,
Self::ThreeGp => false,
Self::ThreeG2 => false,
// Images
Self::Apng => false,
Self::Avif => false,
Self::Bmp => false,
Self::Gif => false,
Self::Ico => false,
Self::Jpg => false,
Self::Png => false,
Self::Qoi => false,
Self::Tiff => false,
Self::Webp => false,
// Documents
Self::Pdf => false,
Self::Rtf => false,
// Archives
Self::Arc => false,
Self::Bz => false,
Self::Bz2 => false,
Self::Gz => false,
Self::Jar => false,
Self::Ogg => false,
Self::Rar => false,
Self::SevenZ => false,
Self::Tar => false,
Self::Zip => false,
// Fonts
Self::Eot => false,
Self::Otf => false,
Self::Ttf => false,
Self::Woff => false,
Self::Woff2 => false,
// Applications
Self::Abiword => false,
Self::Azw => false,
Self::Cda => false,
Self::Doc => false,
Self::Docx => false,
Self::Epub => false,
Self::Mpkg => false,
Self::Odp => false,
Self::Ods => false,
Self::Odt => false,
Self::Ppt => false,
Self::Pptx => false,
Self::Vsd => false,
Self::Xls => false,
Self::Xlsx => false,
Self::Xul => false,
}
}
} }

View File

@@ -28,4 +28,3 @@ toml = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tower-http = { workspace = true }

View File

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

View File

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

View File

@@ -6,17 +6,17 @@ use std::{
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use maud::{Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use page::{DeviceType, RenderContext, servable::Page}; use page::{DeviceType, RequestContext, page::Page};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Deserialize; use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::{ use crate::{
components::{ components::{
md::{Markdown, meta_from_markdown}, md::{Markdown, backlinks, meta_from_markdown},
misc::FarLink, misc::FarLink,
}, },
pages::{MAIN_TEMPLATE, backlinks, footer}, pages::page_wrapper,
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -113,7 +113,11 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
return Ok(res); return Ok(res);
} }
fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &RenderContext) -> Markup { fn build_list_for_group(
handouts: &[HandoutEntry],
group: &str,
req_ctx: &RequestContext,
) -> Markup {
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile; let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
if mobile { if mobile {
@@ -193,79 +197,73 @@ pub fn handouts() -> Page {
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
MAIN_TEMPLATE Page {
.derive(meta, move |page, ctx| { meta,
let html = html.clone(); html_ttl: Some(TimeDelta::seconds(300)),
immutable: false,
generate_html: Box::new(move |page, ctx| {
let html = html.clone(); // TODO: find a way to not clone here
let index = index.clone(); let index = index.clone();
render(html, index, page, ctx) Box::pin(async move {
}) let handouts = index.get().await;
.html_ttl(Some(TimeDelta::seconds(300)))
} let fallback = html! {
span style="color:var(--yellow)" {
fn render<'a>( "Could not load handouts, something broke."
html: Markup, }
index: Arc<CachedRequest<Result<Vec<HandoutEntry>, reqwest::Error>>>, " "
_page: &'a Page, (
ctx: &'a RenderContext, FarLink(
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> { "https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
Box::pin(async move { "Try this direct link."
let handouts = index.get().await; )
)
let fallback = html! { };
span style="color:var(--yellow)" {
"Could not load handouts, something broke." let warmups = match &*handouts {
} Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", ctx),
" " Err(error) => {
( warn!("Could not load handout index: {error:?}");
FarLink( fallback.clone()
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest", }
"Try this direct link." };
)
) let advanced = match &*handouts {
}; Ok(handouts) => build_list_for_group(handouts, "Advanced", ctx),
Err(_) => fallback,
let warmups = match &*handouts { };
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", ctx),
Err(error) => { let inner = html! {
warn!("Could not load handout index: {error:?}"); @if let Some(backlinks) = backlinks(page, ctx) {
fallback.clone() (backlinks)
} }
};
(html)
let advanced = match &*handouts {
Ok(handouts) => build_list_for_group(handouts, "Advanced", ctx), (Markdown(concat!(
Err(_) => fallback, "## Warm-Ups",
}; "\n\n",
"Students never show up on time. Some come early, some come late. Warm-ups ",
html! { "are my solution to this problem: we hand these out as students walk in, ",
div class="wrapper" style="margin-top:3ex;" { "giving them something to do until we can start the lesson.",
@if let Some(backlinks) = backlinks(ctx) { )))
(backlinks) (warmups)
} br {}
(html) (Markdown(concat!(
"## Advanced",
(Markdown(concat!( "\n\n",
"## Warm-Ups", "The highest level of the ORMC, and the group I spend most of my time with. ",
"\n\n", "Students in ORMC Advanced are in high school, which means ",
"Students never show up on time. Some come early, some come late. Warm-ups ", "they're ~14-18 years old.",
"are my solution to this problem: we hand these out as students walk in, ", )))
"giving them something to do until we can start the lesson.", (advanced)
))) br {}
(warmups) };
br {}
page_wrapper(&page.meta, inner, true).await
(Markdown(concat!( })
"## Advanced", }),
"\n\n", }
"The highest level of the ORMC, and the group I spend most of my time with. ",
"Students in ORMC Advanced are in high school, which means ",
"they're ~14-18 years old.",
)))
(advanced)
br {}
(footer())
}
}
})
} }

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: 5rem; margin-bottom: 5rem"/> <hr style="margin-top: 8rem; margin-bottom: 8rem"/>
## Projects ## Projects

View File

@@ -1,9 +1,5 @@
use maud::{Markup, html}; use maud::html;
use page::{ use page::page::{Page, PageMetadata};
RenderContext,
servable::{Page, PageMetadata},
};
use std::pin::Pin;
use crate::{ use crate::{
components::{ components::{
@@ -12,78 +8,76 @@ use crate::{
md::Markdown, md::Markdown,
misc::FarLink, misc::FarLink,
}, },
pages::{MAIN_TEMPLATE, footer}, pages::page_wrapper,
}; };
pub fn index() -> Page { pub fn index() -> Page {
MAIN_TEMPLATE.derive( Page {
PageMetadata { 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("/assets/img/icon.png".to_owned()),
backlinks: Some(false),
}, },
render,
)
}
fn render<'a>( generate_html: Box::new(move |page, _ctx| {
_page: &'a Page, Box::pin(async {
_ctx: &'a RenderContext, let inner = html! {
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> { h2 id="about" { "About" }
Box::pin(async {
html! {
div class="wrapper" style="margin-top:3ex;" {
h2 id="about" { "About" }
div {
img div {
class="img-placeholder"
src="/assets/img/cover-small.jpg?t=maxdim(20,20)"
data-large="/assets/img/cover-small.jpg"
style="image-rendering:pixelated;float:left;margin:10px;display:block;width:25%;"
{}
div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" { img
"Welcome, you've reached Mark's main page. Here you'll find" class="img-placeholder"
" links to various projects I've worked on." src="/assets/img/cover-small.jpg?t=maxdim(20,20)"
data-large="/assets/img/cover-small.jpg"
style="image-rendering:pixelated;float:left;margin:10px;display:block;width:25%;"
{}
ul { div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" {
li { (MangledBetaEmail {}) } "Welcome, you've reached Mark's main page. Here you'll find"
li { (MangledGoogleEmail {}) } " links to various projects I've worked on."
li { ul {
( li { (MangledBetaEmail {}) }
FarLink( li { (MangledGoogleEmail {}) }
"https://github.com/rm-dr",
html!( li {
(FAIcon::Github) (
"rm-dr" FarLink(
"https://github.com/rm-dr",
html!(
(FAIcon::Github)
"rm-dr"
)
) )
) )
) }
}
li { li {
( (
FarLink( FarLink(
"https://git.betalupi.com", "https://git.betalupi.com",
html!( html!(
(FAIcon::Git) (FAIcon::Git)
"git.betalupi.com" "git.betalupi.com"
)
) )
) )
) }
} }
} }
br style="clear:both;" {}
} }
br style="clear:both;" {}
}
(Markdown(include_str!("index.md"))) (Markdown(include_str!("index.md")))
};
(footer()) page_wrapper(&page.meta, inner, true).await
} })
} }),
})
..Default::default()
}
} }

View File

@@ -1,13 +1,10 @@
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{Markup, PreEscaped, html}; use maud::{DOCTYPE, Markup, PreEscaped, html};
use page::{ use page::page::{Page, PageMetadata};
RenderContext, use std::pin::Pin;
servable::{Page, PageMetadata, PageTemplate},
};
use crate::components::{ use crate::components::{
fa::FAIcon, md::{Markdown, backlinks, meta_from_markdown},
md::{Markdown, meta_from_markdown},
misc::FarLink, misc::FarLink,
}; };
@@ -68,119 +65,111 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
MAIN_TEMPLATE Page {
.derive(meta, move |_page, ctx| { meta,
immutable: true,
html_ttl: Some(TimeDelta::days(1)),
generate_html: Box::new(move |page, ctx| {
let html = html.clone(); let html = html.clone();
Box::pin(async move { Box::pin(async move {
html! { let inner = html! {
div class="wrapper" style="margin-top:3ex;" { @if let Some(backlinks) = backlinks(page, ctx) {
@if let Some(backlinks) = backlinks(ctx) { (backlinks)
(backlinks)
}
(html)
(footer())
} }
}
(html)
};
page_wrapper(&page.meta, inner, true).await
}) })
}) }),
.html_ttl(Some(TimeDelta::days(1))) }
.immutable(true)
} }
// //
// MARK: components // MARK: wrapper
// //
const MAIN_TEMPLATE: PageTemplate = PageTemplate { pub fn page_wrapper<'a>(
// Order matters, base htmx goes first meta: &'a PageMetadata,
scripts_linked: &["/assets/htmx.js", "/assets/htmx-json.js"], inner: Markup,
footer: bool,
// TODO: use htmx for this ) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
scripts_inline: &[" Box::pin(async move {
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)';
};
})
}
"],
styles_inline: &[],
styles_linked: &["/assets/css/main.css"],
extra_meta: &[(
"viewport",
"width=device-width,initial-scale=1,user-scalable=no",
)],
..PageTemplate::const_default()
};
pub fn backlinks(ctx: &RenderContext) -> Option<Markup> {
let mut backlinks = vec![("/", "home")];
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
let last = segments.pop();
let mut end = 0;
for s in segments {
end += s.len();
backlinks.push((&ctx.route[0..=end], s));
end += 1; // trailing slash
}
last.map(|last| {
html! { html! {
div { (DOCTYPE)
@for (url, text) in backlinks { html {
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) } 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() {
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)';
};
})
}
"))
}
} }
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) } body {
main{
div class="wrapper" style=(
// for 404 page. Margin makes it scroll.
match footer {
true => "margin-top:3ex;",
false =>""
}
) {
(inner)
@if footer {
footer {
hr class = "footline" {}
div class = "footContainer" {
p {
"This site was built by hand with "
(FarLink("https://rust-lang.org", "Rust"))
", "
(FarLink("https://maud.lambda.xyz", "Maud"))
", "
(FarLink("https://github.com/connorskees/grass", "Grass"))
", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum"))
"."
}
}
}
}
}
}}
} }
} }
}) })
} }
pub fn footer() -> Markup {
html!(
footer style="margin-top:10rem;" {
hr class = "footline";
div class = "footContainer" {
p {
"This site was built by hand with "
(FarLink("https://rust-lang.org", "Rust"))
", "
(FarLink("https://maud.lambda.xyz", "Maud"))
", and "
(FarLink("https://docs.rs/axum/latest/axum", "Axum"))
". "
(
FarLink(
"https://git.betalupi.com/Mark/webpage",
html!(
(FAIcon::Git)
"Source here!"
)
)
)
}
}
}
)
}

View File

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

View File

@@ -1,39 +1,27 @@
use axum::Router; use axum::Router;
use macro_sass::sass; use macro_sass::sass;
use page::{ use page::{PageServer, asset::StaticAsset, redirect::Redirect};
ServableRoute, use std::sync::Arc;
servable::{Redirect, StaticAsset},
};
use toolbox::mime::MimeType; use toolbox::mime::MimeType;
use tower_http::compression::{CompressionLayer, DefaultPredicate};
use crate::pages; use crate::pages;
pub(super) fn router() -> Router<()> { pub(super) fn router() -> Router<()> {
let compression: CompressionLayer = CompressionLayer::new() build_server().into_router()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true)
.compress_when(DefaultPredicate::new());
build_server().into_router().layer(compression)
} }
fn build_server() -> ServableRoute { fn build_server() -> Arc<PageServer> {
ServableRoute::new() let server = PageServer::new();
#[expect(clippy::unwrap_used)]
server
.with_404(pages::notfound()) .with_404(pages::notfound())
.add_page("/", pages::index()) .add_page("/", pages::index())
.add_page("/links", pages::links()) .add_page("/links", pages::links())
.add_page("/whats-a-betalupi", pages::betalupi()) .add_page("/whats-a-betalupi", pages::betalupi())
.add_page("/handouts", pages::handouts()) .add_page("/handouts", pages::handouts())
.add_page("/htwah", { .add_page("/htwah", Redirect::new("/handouts").unwrap())
#[expect(clippy::unwrap_used)]
Redirect::new("/handouts").unwrap()
})
.add_page("/htwah/typesetting", pages::htwah_typesetting()) .add_page("/htwah/typesetting", pages::htwah_typesetting())
.add_page("/assets/htmx.js", page::HTMX_2_0_8)
.add_page("/assets/htmx-json.js", page::EXT_JSON_1_19_12)
// //
.add_page( .add_page(
"/assets/css/main.css", "/assets/css/main.css",
@@ -197,7 +185,9 @@ fn build_server() -> ServableRoute {
bytes: include_bytes!("../../assets/htwah/spacing-b.pdf"), bytes: include_bytes!("../../assets/htwah/spacing-b.pdf"),
mime: MimeType::Pdf, mime: MimeType::Pdf,
}, },
) );
server
} }
#[test] #[test]
@@ -207,6 +197,7 @@ fn server_builds_without_panic() {
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
let _server = build_server(); // Needs tokio context
let _server = build_server().into_router();
}); });
} }

View File

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