Compare commits

..

7 Commits

Author SHA1 Message Date
b6de727883 page rewrite
Some checks failed
CI / Check typos (push) Successful in 1m3s
CI / Check links (push) Failing after 1m14s
CI / Clippy (push) Successful in 1m43s
CI / Build and test (push) Successful in 1m31s
CI / Build container (push) Successful in 1m45s
CI / Deploy on waypoint (push) Failing after 1m23s
2025-11-16 12:58:20 -08:00
04d98462dd Return 404 for 404 page
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 13s
CI / Clippy (push) Successful in 54s
CI / Build and test (push) Successful in 1m20s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
2025-11-14 09:46:18 -08:00
991eb92562 Tweaks
Some checks failed
CI / Check typos (push) Successful in 28s
CI / Check links (push) Failing after 31s
CI / Clippy (push) Successful in 1m2s
CI / Build and test (push) Successful in 1m21s
CI / Build container (push) Successful in 1m5s
CI / Deploy on waypoint (push) Successful in 44s
2025-11-12 14:18:44 -08:00
529dfc468e README 2025-11-12 14:18:41 -08:00
6493476565 TTL 2025-11-12 13:59:40 -08:00
d5067ff381 404 2025-11-12 13:59:38 -08:00
532cfe58ba Minor tweaks
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Clippy (push) Successful in 59s
CI / Check links (push) Failing after 1m32s
CI / Build and test (push) Successful in 1m21s
CI / Build container (push) Has been skipped
CI / Deploy on waypoint (push) Has been skipped
2025-11-09 21:20:47 -08:00
29 changed files with 1521 additions and 691 deletions

374
Cargo.lock generated
View File

@@ -394,6 +394,12 @@ 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"
@@ -693,6 +699,29 @@ 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"
@@ -737,6 +766,26 @@ 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"
@@ -812,6 +861,27 @@ 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"
@@ -883,6 +953,16 @@ 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"
@@ -909,6 +989,12 @@ 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"
@@ -961,12 +1047,6 @@ 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"
@@ -986,6 +1066,31 @@ 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"
@@ -1002,6 +1107,17 @@ 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"
@@ -1037,6 +1153,7 @@ 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",
@@ -1048,6 +1165,15 @@ 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"
@@ -1058,6 +1184,15 @@ 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"
@@ -1171,11 +1306,6 @@ 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"
@@ -1212,6 +1342,17 @@ 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"
@@ -1681,6 +1822,12 @@ 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"
@@ -1727,21 +1874,18 @@ 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"
@@ -1760,7 +1904,7 @@ dependencies = [
"argparse", "argparse",
"const_format", "const_format",
"derivative", "derivative",
"derive_more", "derive_more 0.99.20",
"downcast-rs", "downcast-rs",
"entities", "entities",
"html-escape", "html-escape",
@@ -1774,6 +1918,28 @@ 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"
@@ -2085,6 +2251,26 @@ 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"
@@ -2097,15 +2283,13 @@ 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-http", "tower",
"tracing", "tracing",
] ]
@@ -2163,6 +2347,16 @@ 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"
@@ -2283,6 +2477,12 @@ 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"
@@ -2829,6 +3029,19 @@ 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"
@@ -2897,6 +3110,40 @@ 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"
@@ -3012,9 +3259,19 @@ 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"
@@ -3130,6 +3387,31 @@ 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"
@@ -3329,6 +3611,30 @@ 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"
@@ -3914,6 +4220,12 @@ 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"
@@ -4134,6 +4446,18 @@ 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,6 +80,8 @@ service-webpage = { path = "crates/service/service-webpage" }
# #
axum = { version = "0.8.6", features = ["macros", "multipart"] } axum = { version = "0.8.6", features = ["macros", "multipart"] }
tower-http = { version = "0.6.6", features = ["trace", "compression-full"] } tower-http = { version = "0.6.6", features = ["trace", "compression-full"] }
tower = { version = "0.5.2" }
serde_urlencoded = { version = "0.7.1" }
utoipa = "5.4.0" utoipa = "5.4.0"
utoipa-swagger-ui = { version = "9.0.2", features = [ utoipa-swagger-ui = { version = "9.0.2", features = [
"axum", "axum",
@@ -93,6 +95,7 @@ emojis = "0.8.0"
reqwest = { version = "0.12.24", default-features = false, features = [ reqwest = { version = "0.12.24", default-features = false, features = [
"http2", "http2",
"rustls-tls", "rustls-tls",
"rustls-tls-webpki-roots", # Need to recompile to update
"cookies", "cookies",
"gzip", "gzip",
"stream", "stream",
@@ -144,6 +147,9 @@ 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"

23
README.md Normal file
View File

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

View File

@@ -9,7 +9,6 @@ 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 }
@@ -17,7 +16,6 @@ tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
maud = { workspace = true } maud = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
parking_lot = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
lru = { workspace = true } tower = { workspace = true }
tower-http = { workspace = true } serde_urlencoded = { workspace = true }

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -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::{Rendered, RenderedBody, RequestContext, Servable}; use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
pub struct StaticAsset { pub struct StaticAsset {
pub bytes: &'static [u8], pub bytes: &'static [u8],
@@ -13,10 +13,69 @@ 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 RequestContext, ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
Box::pin(async { Box::pin(async {
let ttl = Some(TimeDelta::days(30)); let ttl = Some(TimeDelta::days(30));

View File

@@ -1,3 +1,26 @@
pub mod asset; mod asset;
pub mod page; pub use asset::*;
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::{Markup, Render, html}; use maud::{DOCTYPE, Markup, PreEscaped, html};
use serde::Deserialize; use serde::Deserialize;
use std::pin::Pin; use std::{pin::Pin, sync::Arc};
use toolbox::mime::MimeType; use toolbox::mime::MimeType;
use crate::{Rendered, RenderedBody, RequestContext, Servable}; use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
// //
// MARK: metadata // MARK: metadata
@@ -17,7 +17,6 @@ pub struct PageMetadata {
pub author: Option<String>, pub author: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub image: Option<String>, pub image: Option<String>,
pub backlinks: Option<bool>,
} }
impl Default for PageMetadata { impl Default for PageMetadata {
@@ -27,42 +26,15 @@ impl Default for PageMetadata {
author: None, author: None,
description: None, description: None,
image: None, image: None,
backlinks: None,
} }
} }
} }
impl Render for PageMetadata {
fn render(&self) -> Markup {
let empty = String::new();
let title = &self.title;
let author = &self.author.as_ref().unwrap_or(&empty);
let description = &self.description.as_ref().unwrap_or(&empty);
let image = &self.image.as_ref().unwrap_or(&empty);
html !(
meta property="og:site_name" content=(title) {}
meta name="title" content=(title) {}
meta property="og:title" content=(title) {}
meta property="twitter:title" content=(title) {}
meta name="author" content=(author) {}
meta name="description" content=(description) {}
meta property="og:description" content=(description) {}
meta property="twitter:description" content=(description) {}
meta content=(image) property="og:image" {}
link rel="shortcut icon" href=(image) type="image/x-icon" {}
)
}
}
// //
// MARK: page // MARK: page
// //
// Some HTML #[derive(Clone)]
pub struct Page { pub struct Page {
pub meta: PageMetadata, pub meta: PageMetadata,
pub immutable: bool, pub immutable: bool,
@@ -79,46 +51,101 @@ pub struct Page {
/// or the contents of a wrapper element (defined in the page server struct). /// or the contents of a wrapper element (defined in the page server struct).
/// ///
/// This closure must never return `<html>` or `<head>`. /// This closure must never return `<html>` or `<head>`.
pub generate_html: Box< pub generate_html: Arc<
dyn Send dyn Send
+ Sync + Sync
+ 'static
+ for<'a> Fn( + for<'a> Fn(
&'a Page, &'a Page,
&'a RequestContext, &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>, ) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>>,
>, >,
pub response_code: StatusCode,
pub scripts_inline: Vec<String>,
pub scripts_linked: Vec<String>,
pub styles_linked: Vec<String>,
pub styles_inline: Vec<String>,
/// `name`, `content` for extra `<meta>` tags
pub extra_meta: Vec<(String, String)>,
} }
impl Default for Page { impl Default for Page {
fn default() -> Self { fn default() -> Self {
Page { Page {
// No cache by default
html_ttl: None,
immutable: false,
meta: Default::default(), meta: Default::default(),
html_ttl: Some(TimeDelta::seconds(60 * 60 * 24 * 30)), generate_html: Arc::new(|_, _| Box::pin(async { html!() })),
//css_ttl: Duration::from_secs(60 * 60 * 24 * 30), response_code: StatusCode::OK,
//generate_css: None, scripts_inline: Vec::new(),
generate_html: Box::new(|_, _| Box::pin(async { html!() })), scripts_linked: Vec::new(),
immutable: true, styles_inline: Vec::new(),
styles_linked: Vec::new(),
extra_meta: Vec::new(),
} }
} }
} }
impl Page { impl Page {
pub async fn generate_html(&self, ctx: &RequestContext) -> Markup { pub async fn generate_html(&self, ctx: &RenderContext) -> Markup {
(self.generate_html)(self, ctx).await (self.generate_html)(self, ctx).await
} }
pub fn immutable(mut self, immutable: bool) -> Self {
self.immutable = immutable;
self
}
pub fn html_ttl(mut self, html_ttl: Option<TimeDelta>) -> Self {
self.html_ttl = html_ttl;
self
}
pub fn response_code(mut self, response_code: StatusCode) -> Self {
self.response_code = response_code;
self
}
pub fn with_script_inline(mut self, script: impl Into<String>) -> Self {
self.scripts_inline.push(script.into());
self
}
pub fn with_script_linked(mut self, url: impl Into<String>) -> Self {
self.scripts_linked.push(url.into());
self
}
pub fn with_style_inline(mut self, style: impl Into<String>) -> Self {
self.styles_inline.push(style.into());
self
}
pub fn with_style_linked(mut self, url: impl Into<String>) -> Self {
self.styles_linked.push(url.into());
self
}
pub fn with_extra_meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_meta.push((key.into(), value.into()));
self
}
} }
impl Servable for Page { impl Servable for Page {
fn render<'a>( fn head<'a>(
&'a self, &'a self,
ctx: &'a RequestContext, _ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async { Box::pin(async {
let html = self.generate_html(ctx).await;
return Rendered { return Rendered {
code: StatusCode::OK, code: self.response_code,
body: RenderedBody::Markup(html), body: (),
ttl: self.html_ttl, ttl: self.html_ttl,
immutable: self.immutable, immutable: self.immutable,
headers: HeaderMap::new(), headers: HeaderMap::new(),
@@ -126,4 +153,157 @@ 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::{Rendered, RenderedBody, RequestContext, Servable}; use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
pub struct Redirect { pub struct Redirect {
to: HeaderValue, to: HeaderValue,
@@ -20,10 +20,10 @@ impl Redirect {
} }
impl Servable for Redirect { impl Servable for Redirect {
fn render<'a>( fn head<'a>(
&'a self, &'a self,
_ctx: &'a RequestContext, _ctx: &'a RenderContext,
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> { ) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
Box::pin(async { Box::pin(async {
let mut headers = HeaderMap::with_capacity(1); let mut headers = HeaderMap::with_capacity(1);
headers.append(header::LOCATION, self.to.clone()); headers.append(header::LOCATION, self.to.clone());
@@ -31,11 +31,18 @@ impl Servable for Redirect {
return Rendered { return Rendered {
code: StatusCode::PERMANENT_REDIRECT, code: StatusCode::PERMANENT_REDIRECT,
headers, headers,
body: RenderedBody::Empty, body: (),
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

@@ -1,310 +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 PageServer {
/// If true, expired pages will be rerendered before being sent to the user.
/// If false, requests never trigger rerenders. We rely on the rerender task.
///
/// If true, we deliver fresher pages but delay responses.
/// TODO: replace this with a smarter rendering strategy?
never_rerender_on_request: bool,
/// Map of `{ route: page }`
pages: Arc<Mutex<HashMap<String, Arc<dyn Servable>>>>,
/// Map of `{ route: (page data, expire time) }`
///
/// We use an LruCache for bounded memory usage.
page_cache: Mutex<LruCache<RequestContext, (Rendered, DateTime<Utc>)>>,
}
impl PageServer {
pub fn new() -> Arc<Self> {
#[expect(clippy::unwrap_used)]
let cache_size = NonZero::new(128).unwrap();
Arc::new(Self {
pages: Arc::new(Mutex::new(HashMap::new())),
page_cache: Mutex::new(LruCache::new(cache_size)),
never_rerender_on_request: true,
})
}
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
#[expect(clippy::expect_used)]
let route = route
.into()
.strip_prefix("/")
.expect("route must start with /")
.to_owned();
self.pages.lock().insert(route, Arc::new(page));
self
}
/// Re-render the page at `route`, regardless of cache state.
/// Does nothing if there is no page at `route`.
///
/// Returns the rendered page's content.
async fn render_page(
&self,
reason: &'static str,
route: &str,
ctx: RequestContext,
) -> Option<(Rendered, Option<DateTime<Utc>>)> {
let now = Utc::now();
let start = Instant::now();
let page = match self.pages.lock().get(route) {
Some(x) => x.clone(),
None => return None,
};
trace!(
message = "Rendering page",
route,
reason,
lock_time_ms = start.elapsed().as_millis()
);
let rendered = page.render(&ctx).await;
//let html = (self.render_page)(&page, &req_ctx).await.0;
let mut expires = None;
if let Some(ttl) = rendered.ttl {
expires = Some(now + ttl);
self.page_cache
.lock()
.put(ctx, (rendered.clone(), now + ttl));
}
let elapsed = start.elapsed().as_millis();
trace!(message = "Rendered page", route, reason, time_ms = elapsed);
return Some((rendered, expires));
}
async fn handler(
Path(route): Path<String>,
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 = match state.render_page("request", &route, ctx).await {
Some(x) => Some(x.clone()),
None => {
trace!(
message = "Not found",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type
);
trace!(
message = "Served route",
route,
addr = ?addr.addr,
user_agent = ua,
device_type = ?client_info.device_type,
cached,
time_ns = start.elapsed().as_nanos()
);
return StatusCode::NOT_FOUND.into_response();
}
};
}
#[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 +1,66 @@
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 RequestContext { pub struct RenderContext {
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)]
@@ -25,10 +75,6 @@ impl Default for DeviceType {
} }
} }
//
// MARK: clientinfo
//
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ClientInfo { pub struct ClientInfo {
/// This is an estimate, but it's probably good enough. /// This is an estimate, but it's probably good enough.

View File

@@ -38,6 +38,20 @@ 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,6 +251,23 @@ 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;
@@ -687,4 +704,108 @@ 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,3 +28,4 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, RequestContext, page::Page}; use page::{DeviceType, RenderContext, servable::Page};
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Deserialize; use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::{ use crate::{
components::{ components::{
md::{Markdown, backlinks, meta_from_markdown}, md::{Markdown, meta_from_markdown},
misc::FarLink, misc::FarLink,
}, },
pages::page_wrapper, pages::{MAIN_TEMPLATE, backlinks, footer},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -113,11 +113,7 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
return Ok(res); return Ok(res);
} }
fn build_list_for_group( fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &RenderContext) -> Markup {
handouts: &[HandoutEntry],
group: &str,
req_ctx: &RequestContext,
) -> Markup {
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile; let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
if mobile { if mobile {
@@ -197,73 +193,79 @@ pub fn handouts() -> Page {
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
Page { MAIN_TEMPLATE
meta, .derive(meta, move |page, ctx| {
html_ttl: Some(TimeDelta::seconds(300)), let html = html.clone();
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();
Box::pin(async move { render(html, index, page, ctx)
let handouts = index.get().await; })
.html_ttl(Some(TimeDelta::seconds(300)))
let fallback = html! { }
span style="color:var(--yellow)" {
"Could not load handouts, something broke." fn render<'a>(
} html: Markup,
" " index: Arc<CachedRequest<Result<Vec<HandoutEntry>, reqwest::Error>>>,
( _page: &'a Page,
FarLink( ctx: &'a RenderContext,
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest", ) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
"Try this direct link." Box::pin(async move {
) let handouts = index.get().await;
)
}; let fallback = html! {
span style="color:var(--yellow)" {
let warmups = match &*handouts { "Could not load handouts, something broke."
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", ctx), }
Err(error) => { " "
warn!("Could not load handout index: {error:?}"); (
fallback.clone() FarLink(
} "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),
let inner = html! { Err(error) => {
@if let Some(backlinks) = backlinks(page, ctx) { warn!("Could not load handout index: {error:?}");
(backlinks) fallback.clone()
} }
};
(html)
let advanced = match &*handouts {
(Markdown(concat!( Ok(handouts) => build_list_for_group(handouts, "Advanced", ctx),
"## Warm-Ups", Err(_) => fallback,
"\n\n", };
"Students never show up on time. Some come early, some come late. Warm-ups ",
"are my solution to this problem: we hand these out as students walk in, ", html! {
"giving them something to do until we can start the lesson.", div class="wrapper" style="margin-top:3ex;" {
))) @if let Some(backlinks) = backlinks(ctx) {
(warmups) (backlinks)
br {} }
(Markdown(concat!( (html)
"## Advanced",
"\n\n", (Markdown(concat!(
"The highest level of the ORMC, and the group I spend most of my time with. ", "## Warm-Ups",
"Students in ORMC Advanced are in high school, which means ", "\n\n",
"they're ~14-18 years old.", "Students never show up on time. Some come early, some come late. Warm-ups ",
))) "are my solution to this problem: we hand these out as students walk in, ",
(advanced) "giving them something to do until we can start the lesson.",
br {} )))
}; (warmups)
br {}
page_wrapper(&page.meta, inner).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,11 +5,11 @@ Also see [what's a "betalupi?"](/whats-a-betalupi)
- [Handouts](/handouts): Math circle lessons I've written - [Handouts](/handouts): Math circle lessons I've written
- [Links](/links): Interesting parts of the internet - [Links](/links): Interesting parts of the internet
<hr style="margin-top: 8rem; margin-bottom: 8rem"/> <hr style="margin-top: 5rem; margin-bottom: 5rem"/>
## Projects ## Projects
- **RedoxOS**, a general-purpose, microkernel-based operating system written in Rust. _{{color(--grey, "[enthusiast]")}} - **RedoxOS**, a general-purpose, microkernel-based operating system written in Rust. _{{color(--grey, "[enthusiast]")}}_
- {{color(--grey, "Status: ")}} {{color(--yellow, "Passive.")}} - {{color(--grey, "Status: ")}} {{color(--yellow, "Passive.")}}
- {{color(--grey, "Website: ")}} [:fa-link: redox-os.org](https://www.redox-os.org/) - {{color(--grey, "Website: ")}} [:fa-link: redox-os.org](https://www.redox-os.org/)

View File

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

View File

@@ -1,18 +1,23 @@
use chrono::TimeDelta; use chrono::TimeDelta;
use maud::{DOCTYPE, Markup, PreEscaped, html}; use maud::{Markup, PreEscaped, html};
use page::page::{Page, PageMetadata}; use page::{
use std::pin::Pin; RenderContext,
servable::{Page, PageMetadata, PageTemplate},
};
use crate::components::{ use crate::components::{
md::{Markdown, backlinks, meta_from_markdown}, fa::FAIcon,
md::{Markdown, meta_from_markdown},
misc::FarLink, misc::FarLink,
}; };
mod handouts; mod handouts;
mod index; mod index;
mod notfound;
pub use handouts::handouts; pub use handouts::handouts;
pub use index::index; pub use index::index;
pub use notfound::notfound;
pub fn links() -> Page { pub fn links() -> Page {
/* /*
@@ -63,98 +68,119 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
let html = PreEscaped(md.render()); let html = PreEscaped(md.render());
Page { MAIN_TEMPLATE
meta, .derive(meta, move |_page, ctx| {
immutable: true,
html_ttl: Some(TimeDelta::seconds(60 * 24 * 30)),
generate_html: Box::new(move |page, ctx| {
let html = html.clone(); let html = html.clone();
Box::pin(async move { Box::pin(async move {
let inner = html! { html! {
@if let Some(backlinks) = backlinks(page, ctx) { div class="wrapper" style="margin-top:3ex;" {
(backlinks) @if let Some(backlinks) = backlinks(ctx) {
(backlinks)
}
(html)
(footer())
} }
}
(html)
};
page_wrapper(&page.meta, inner).await
}) })
}), })
} .html_ttl(Some(TimeDelta::days(1)))
.immutable(true)
} }
// //
// MARK: wrapper // MARK: components
// //
pub fn page_wrapper<'a>( const MAIN_TEMPLATE: PageTemplate = PageTemplate {
meta: &'a PageMetadata, // Order matters, base htmx goes first
inner: Markup, scripts_linked: &["/assets/htmx.js", "/assets/htmx-json.js"],
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
Box::pin(async move { // TODO: use htmx for this
scripts_inline: &["
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! {
(DOCTYPE) div {
html { @for (url, text) in backlinks {
head { a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
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())) }
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)';
};
})
}
"))
}
} }
body { span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
div class="wrapper" {
main { (inner) }
footer {
hr class = "footline" {}
div class = "footContainer" {
p {
"This site was built by hand using "
(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

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

View File

@@ -1,26 +1,39 @@
use axum::Router; use axum::Router;
use macro_sass::sass; use macro_sass::sass;
use page::{PageServer, asset::StaticAsset, redirect::Redirect}; use page::{
use std::sync::Arc; ServableRoute,
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<()> {
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> { fn build_server() -> ServableRoute {
let server = PageServer::new(); ServableRoute::new()
.with_404(pages::notfound())
#[expect(clippy::unwrap_used)]
server
.add_page("/", pages::index()) .add_page("/", pages::index())
.add_page("/links", pages::links()) .add_page("/links", pages::links())
.add_page("/whats-a-betalupi", pages::betalupi()) .add_page("/whats-a-betalupi", pages::betalupi())
.add_page("/handouts", pages::handouts()) .add_page("/handouts", pages::handouts())
.add_page("/htwah", Redirect::new("/handouts").unwrap()) .add_page("/htwah", {
#[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",
@@ -184,9 +197,7 @@ fn build_server() -> Arc<PageServer> {
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]
@@ -196,7 +207,6 @@ fn server_builds_without_panic() {
.build() .build()
.unwrap() .unwrap()
.block_on(async { .block_on(async {
// Needs tokio context let _server = build_server();
let _server = build_server().into_router();
}); });
} }

View File

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