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
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
This commit is contained in:
374
Cargo.lock
generated
374
Cargo.lock
generated
@@ -394,6 +394,12 @@ version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
@@ -693,6 +699,29 @@ dependencies = [
|
||||
"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]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -737,6 +766,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "diff"
|
||||
version = "0.1.13"
|
||||
@@ -812,6 +861,27 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -883,6 +953,16 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "exr"
|
||||
version = "1.73.0"
|
||||
@@ -909,6 +989,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.6"
|
||||
@@ -961,12 +1047,6 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -986,6 +1066,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
@@ -1002,6 +1107,17 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
@@ -1037,6 +1153,7 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
@@ -1048,6 +1165,15 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fxhash"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
@@ -1058,6 +1184,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
@@ -1171,11 +1306,6 @@ name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -1212,6 +1342,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "http"
|
||||
version = "1.3.1"
|
||||
@@ -1681,6 +1822,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.1"
|
||||
@@ -1727,21 +1874,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "macro-sass"
|
||||
version = "0.0.1"
|
||||
@@ -1760,7 +1904,7 @@ dependencies = [
|
||||
"argparse",
|
||||
"const_format",
|
||||
"derivative",
|
||||
"derive_more",
|
||||
"derive_more 0.99.20",
|
||||
"downcast-rs",
|
||||
"entities",
|
||||
"html-escape",
|
||||
@@ -1774,6 +1918,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2085,6 +2251,26 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "owo-colors"
|
||||
version = "4.2.3"
|
||||
@@ -2097,15 +2283,13 @@ version = "0.0.1"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"libservice",
|
||||
"lru",
|
||||
"maud",
|
||||
"parking_lot",
|
||||
"pixel-transform",
|
||||
"serde",
|
||||
"serde_urlencoded",
|
||||
"tokio",
|
||||
"toolbox",
|
||||
"tower-http",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -2163,6 +2347,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
@@ -2283,6 +2477,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
@@ -2829,6 +3029,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustls"
|
||||
version = "0.23.34"
|
||||
@@ -2897,6 +3110,40 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -3012,9 +3259,19 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml 0.9.8",
|
||||
"toolbox",
|
||||
"tower-http",
|
||||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3130,6 +3387,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -3329,6 +3611,30 @@ version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "term"
|
||||
version = "0.7.0"
|
||||
@@ -3914,6 +4220,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-width"
|
||||
version = "0.1.7"
|
||||
@@ -4134,6 +4446,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "webpage"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -80,6 +80,8 @@ service-webpage = { path = "crates/service/service-webpage" }
|
||||
#
|
||||
axum = { version = "0.8.6", features = ["macros", "multipart"] }
|
||||
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-swagger-ui = { version = "9.0.2", features = [
|
||||
"axum",
|
||||
@@ -93,6 +95,7 @@ emojis = "0.8.0"
|
||||
reqwest = { version = "0.12.24", default-features = false, features = [
|
||||
"http2",
|
||||
"rustls-tls",
|
||||
"rustls-tls-webpki-roots", # Need to recompile to update
|
||||
"cookies",
|
||||
"gzip",
|
||||
"stream",
|
||||
@@ -144,6 +147,9 @@ lru = "0.16.2"
|
||||
parking_lot = "0.12.5"
|
||||
lazy_static = "1.5.0"
|
||||
image = "0.25.8"
|
||||
scraper = "0.24.0"
|
||||
futures = "0.3.31"
|
||||
tempfile = "3.23.0"
|
||||
|
||||
# md_* test utilities
|
||||
prettydiff = "0.9.0"
|
||||
|
||||
@@ -9,7 +9,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
toolbox = { workspace = true }
|
||||
libservice = { workspace = true }
|
||||
pixel-transform = { workspace = true }
|
||||
|
||||
axum = { workspace = true }
|
||||
@@ -17,7 +16,6 @@ tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
maud = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
lru = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
|
||||
1
crates/lib/page/htmx/htmx-2.0.8.min.js
vendored
Normal file
1
crates/lib/page/htmx/htmx-2.0.8.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
crates/lib/page/htmx/json-enc-1.9.12.js
Normal file
11
crates/lib/page/htmx/json-enc-1.9.12.js
Normal 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));
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,25 @@
|
||||
mod servable;
|
||||
pub use servable::*;
|
||||
//! A web stack for embedded uis.
|
||||
//!
|
||||
//! Featuring:
|
||||
//! - htmx
|
||||
//! - axum
|
||||
//! - rust
|
||||
//! - and maud
|
||||
|
||||
mod requestcontext;
|
||||
pub use requestcontext::*;
|
||||
pub mod servable;
|
||||
|
||||
mod server;
|
||||
pub use server::*;
|
||||
mod types;
|
||||
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,
|
||||
};
|
||||
|
||||
275
crates/lib/page/src/route.rs
Normal file
275
crates/lib/page/src/route.rs
Normal 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(¬found);
|
||||
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(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::{pin::Pin, str::FromStr};
|
||||
use toolbox::mime::MimeType;
|
||||
use tracing::{error, trace};
|
||||
|
||||
use crate::{Rendered, RenderedBody, RequestContext, Servable};
|
||||
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
|
||||
|
||||
pub struct StaticAsset {
|
||||
pub bytes: &'static [u8],
|
||||
@@ -13,10 +13,69 @@ pub struct 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>(
|
||||
&'a self,
|
||||
ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let ttl = Some(TimeDelta::days(30));
|
||||
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
pub mod asset;
|
||||
pub mod page;
|
||||
pub mod redirect;
|
||||
mod asset;
|
||||
pub use asset::*;
|
||||
|
||||
mod page;
|
||||
pub use page::*;
|
||||
|
||||
mod redirect;
|
||||
pub use redirect::*;
|
||||
|
||||
/// Something that may be served over http.
|
||||
pub trait Servable: Send + Sync {
|
||||
/// Return the same response as [Servable::render], but with an empty body.
|
||||
/// Used to respond to `HEAD` requests.
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
ctx: &'a crate::RenderContext,
|
||||
) -> std::pin::Pin<Box<dyn Future<Output = crate::Rendered<()>> + 'a + Send + Sync>>;
|
||||
|
||||
/// Render this page
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a crate::RenderContext,
|
||||
) -> std::pin::Pin<
|
||||
Box<dyn Future<Output = crate::Rendered<crate::RenderedBody>> + 'a + Send + Sync>,
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use chrono::TimeDelta;
|
||||
use maud::{Markup, Render, html};
|
||||
use maud::{DOCTYPE, Markup, PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
use std::pin::Pin;
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
use toolbox::mime::MimeType;
|
||||
|
||||
use crate::{Rendered, RenderedBody, RequestContext, Servable};
|
||||
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
|
||||
|
||||
//
|
||||
// MARK: metadata
|
||||
@@ -17,7 +17,6 @@ pub struct PageMetadata {
|
||||
pub author: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub backlinks: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for PageMetadata {
|
||||
@@ -27,42 +26,15 @@ impl Default for PageMetadata {
|
||||
author: None,
|
||||
description: None,
|
||||
image: None,
|
||||
backlinks: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PageMetadata {
|
||||
fn render(&self) -> Markup {
|
||||
let empty = String::new();
|
||||
let title = &self.title;
|
||||
let author = &self.author.as_ref().unwrap_or(&empty);
|
||||
let description = &self.description.as_ref().unwrap_or(&empty);
|
||||
let image = &self.image.as_ref().unwrap_or(&empty);
|
||||
|
||||
html !(
|
||||
meta property="og:site_name" content=(title) {}
|
||||
meta name="title" content=(title) {}
|
||||
meta property="og:title" content=(title) {}
|
||||
meta property="twitter:title" content=(title) {}
|
||||
|
||||
meta name="author" content=(author) {}
|
||||
|
||||
meta name="description" content=(description) {}
|
||||
meta property="og:description" content=(description) {}
|
||||
meta property="twitter:description" content=(description) {}
|
||||
|
||||
meta content=(image) property="og:image" {}
|
||||
link rel="shortcut icon" href=(image) type="image/x-icon" {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: page
|
||||
//
|
||||
|
||||
// Some HTML
|
||||
#[derive(Clone)]
|
||||
pub struct Page {
|
||||
pub meta: PageMetadata,
|
||||
pub immutable: bool,
|
||||
@@ -79,47 +51,101 @@ pub struct Page {
|
||||
/// or the contents of a wrapper element (defined in the page server struct).
|
||||
///
|
||||
/// This closure must never return `<html>` or `<head>`.
|
||||
pub generate_html: Box<
|
||||
pub generate_html: Arc<
|
||||
dyn Send
|
||||
+ Sync
|
||||
+ 'static
|
||||
+ for<'a> Fn(
|
||||
&'a Page,
|
||||
&'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>>,
|
||||
&'a RenderContext,
|
||||
) -> 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 {
|
||||
fn default() -> Self {
|
||||
Page {
|
||||
// No cache by default
|
||||
html_ttl: None,
|
||||
immutable: false,
|
||||
|
||||
meta: Default::default(),
|
||||
html_ttl: Some(TimeDelta::days(1)),
|
||||
generate_html: Box::new(|_, _| Box::pin(async { html!() })),
|
||||
immutable: true,
|
||||
generate_html: Arc::new(|_, _| Box::pin(async { html!() })),
|
||||
response_code: StatusCode::OK,
|
||||
scripts_inline: Vec::new(),
|
||||
scripts_linked: Vec::new(),
|
||||
styles_inline: Vec::new(),
|
||||
styles_linked: Vec::new(),
|
||||
extra_meta: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
fn render<'a>(
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = crate::Rendered> + 'a + Send + Sync>> {
|
||||
_ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let html = self.generate_html(ctx).await;
|
||||
|
||||
return Rendered {
|
||||
code: self.response_code,
|
||||
body: RenderedBody::Markup(html),
|
||||
body: (),
|
||||
ttl: self.html_ttl,
|
||||
immutable: self.immutable,
|
||||
headers: HeaderMap::new(),
|
||||
@@ -127,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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::http::{
|
||||
header::{self, InvalidHeaderValue},
|
||||
};
|
||||
|
||||
use crate::{Rendered, RenderedBody, RequestContext, Servable};
|
||||
use crate::{RenderContext, Rendered, RenderedBody, servable::Servable};
|
||||
|
||||
pub struct Redirect {
|
||||
to: HeaderValue,
|
||||
@@ -20,10 +20,10 @@ impl Redirect {
|
||||
}
|
||||
|
||||
impl Servable for Redirect {
|
||||
fn render<'a>(
|
||||
fn head<'a>(
|
||||
&'a self,
|
||||
_ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
|
||||
_ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<()>> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
let mut headers = HeaderMap::with_capacity(1);
|
||||
headers.append(header::LOCATION, self.to.clone());
|
||||
@@ -31,11 +31,18 @@ impl Servable for Redirect {
|
||||
return Rendered {
|
||||
code: StatusCode::PERMANENT_REDIRECT,
|
||||
headers,
|
||||
body: RenderedBody::Empty,
|
||||
body: (),
|
||||
ttl: None,
|
||||
immutable: true,
|
||||
mime: None,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered<RenderedBody>> + 'a + Send + Sync>> {
|
||||
Box::pin(async { self.head(ctx).await.with_body(RenderedBody::Empty) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use libservice::ServiceConnectInfo;
|
||||
use lru::LruCache;
|
||||
use maud::Markup;
|
||||
use parking_lot::Mutex;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
num::NonZero,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::Instant,
|
||||
};
|
||||
use toolbox::mime::MimeType;
|
||||
use tower_http::compression::{CompressionLayer, DefaultPredicate};
|
||||
use tracing::trace;
|
||||
|
||||
use crate::{ClientInfo, RequestContext};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum RenderedBody {
|
||||
Markup(Markup),
|
||||
Static(&'static [u8]),
|
||||
Bytes(Vec<u8>),
|
||||
String(String),
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Rendered {
|
||||
pub code: StatusCode,
|
||||
pub headers: HeaderMap,
|
||||
pub body: RenderedBody,
|
||||
pub mime: Option<MimeType>,
|
||||
|
||||
/// How long to cache this response.
|
||||
/// If none, don't cache.
|
||||
pub ttl: Option<TimeDelta>,
|
||||
pub immutable: bool,
|
||||
}
|
||||
|
||||
pub trait Servable: Send + Sync {
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>>;
|
||||
}
|
||||
|
||||
pub struct Default404 {}
|
||||
impl Servable for Default404 {
|
||||
fn render<'a>(
|
||||
&'a self,
|
||||
_ctx: &'a RequestContext,
|
||||
) -> Pin<Box<dyn Future<Output = Rendered> + 'a + Send + Sync>> {
|
||||
Box::pin(async {
|
||||
return Rendered {
|
||||
code: StatusCode::NOT_FOUND,
|
||||
body: RenderedBody::String("page not found".into()),
|
||||
ttl: Some(TimeDelta::days(1)),
|
||||
immutable: true,
|
||||
headers: HeaderMap::new(),
|
||||
mime: Some(MimeType::Html),
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: server
|
||||
//
|
||||
|
||||
pub struct PageServer {
|
||||
/// If true, expired pages will be rerendered before being sent to the user.
|
||||
/// If false, requests never trigger rerenders. We rely on the rerender task.
|
||||
///
|
||||
/// If true, we deliver fresher pages but delay responses.
|
||||
/// TODO: replace this with a smarter rendering strategy?
|
||||
never_rerender_on_request: bool,
|
||||
|
||||
/// Map of `{ route: page }`
|
||||
pages: Mutex<HashMap<String, Arc<dyn Servable>>>,
|
||||
|
||||
notfound: Mutex<Arc<dyn Servable>>,
|
||||
|
||||
/// Map of `{ route: (page data, expire time) }`
|
||||
///
|
||||
/// We use an LruCache for bounded memory usage.
|
||||
page_cache: Mutex<LruCache<RequestContext, (Rendered, DateTime<Utc>)>>,
|
||||
}
|
||||
|
||||
impl PageServer {
|
||||
pub fn new() -> Arc<Self> {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let cache_size = NonZero::new(128).unwrap();
|
||||
|
||||
Arc::new(Self {
|
||||
pages: Mutex::new(HashMap::new()),
|
||||
page_cache: Mutex::new(LruCache::new(cache_size)),
|
||||
never_rerender_on_request: true,
|
||||
notfound: Mutex::new(Arc::new(Default404 {})),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set this server's "not found" page
|
||||
pub fn with_404<S: Servable + 'static>(&self, page: S) -> &Self {
|
||||
*self.notfound.lock() = Arc::new(page);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_page<S: Servable + 'static>(&self, route: impl Into<String>, page: S) -> &Self {
|
||||
#[expect(clippy::expect_used)]
|
||||
let route = route
|
||||
.into()
|
||||
.strip_prefix("/")
|
||||
.expect("route must start with /")
|
||||
.to_owned();
|
||||
|
||||
self.pages.lock().insert(route, Arc::new(page));
|
||||
self
|
||||
}
|
||||
|
||||
/// Re-render the page at `route`, regardless of cache state.
|
||||
/// Does nothing if there is no page at `route`.
|
||||
///
|
||||
/// Returns the rendered page's content.
|
||||
async fn render_page(
|
||||
&self,
|
||||
reason: &'static str,
|
||||
route: &str,
|
||||
ctx: RequestContext,
|
||||
) -> (Rendered, Option<DateTime<Utc>>) {
|
||||
let now = Utc::now();
|
||||
let start = Instant::now();
|
||||
|
||||
let page = match self.pages.lock().get(route) {
|
||||
Some(x) => x.clone(),
|
||||
None => self.notfound.lock().clone(),
|
||||
};
|
||||
|
||||
trace!(
|
||||
message = "Rendering page",
|
||||
route = route.to_owned(),
|
||||
reason,
|
||||
lock_time_ms = start.elapsed().as_millis()
|
||||
);
|
||||
|
||||
let rendered = page.render(&ctx).await;
|
||||
|
||||
let mut expires = None;
|
||||
if let Some(ttl) = rendered.ttl {
|
||||
expires = Some(now + ttl);
|
||||
self.page_cache
|
||||
.lock()
|
||||
.put(ctx, (rendered.clone(), now + ttl));
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_millis();
|
||||
trace!(
|
||||
message = "Rendered page",
|
||||
route = route.to_owned(),
|
||||
reason,
|
||||
time_ms = elapsed
|
||||
);
|
||||
return (rendered, expires);
|
||||
}
|
||||
|
||||
async fn handler(
|
||||
Path(route): Path<String>,
|
||||
Query(query): Query<BTreeMap<String, String>>,
|
||||
State(state): State<Arc<Self>>,
|
||||
ConnectInfo(addr): ConnectInfo<ServiceConnectInfo>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let client_info = ClientInfo::from_headers(&headers);
|
||||
let ua = headers
|
||||
.get("user-agent")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
trace!(
|
||||
message = "Serving route",
|
||||
route,
|
||||
addr = ?addr.addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type
|
||||
);
|
||||
|
||||
// Normalize url with redirect
|
||||
if route.ends_with('/') || route.contains("//") || route.starts_with('/') {
|
||||
let mut new_route = route.clone();
|
||||
while new_route.contains("//") {
|
||||
new_route = new_route.replace("//", "/");
|
||||
}
|
||||
let new_route = new_route.trim_matches('/');
|
||||
|
||||
trace!(
|
||||
message = "Redirecting",
|
||||
route,
|
||||
new_route,
|
||||
addr = ?addr.addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::with_capacity(2);
|
||||
|
||||
let new_route = match HeaderValue::from_str(&format!("/{new_route}")) {
|
||||
Ok(x) => x,
|
||||
Err(_) => {
|
||||
// Be extra careful, this is user-provided data
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
headers.append(header::LOCATION, new_route);
|
||||
headers.append("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
|
||||
return (StatusCode::PERMANENT_REDIRECT, headers).into_response();
|
||||
}
|
||||
|
||||
let ctx = RequestContext {
|
||||
client_info,
|
||||
route: format!("/{route}"),
|
||||
query,
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let mut html_expires = None;
|
||||
let mut cached = true;
|
||||
|
||||
// Get from cache, if available
|
||||
if let Some((html, expires)) = state.page_cache.lock().get(&ctx)
|
||||
&& (*expires > now || state.never_rerender_on_request)
|
||||
{
|
||||
html_expires = Some((html.clone(), Some(*expires)));
|
||||
};
|
||||
|
||||
if html_expires.is_none() {
|
||||
cached = false;
|
||||
html_expires = Some(state.render_page("request", &route, ctx).await);
|
||||
}
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
let (mut html, expires) = html_expires.unwrap();
|
||||
|
||||
if !html.headers.contains_key(header::CACHE_CONTROL) {
|
||||
let max_age = match expires {
|
||||
Some(expires) => (expires - now).num_seconds().max(1),
|
||||
None => 1,
|
||||
};
|
||||
|
||||
let mut value = String::new();
|
||||
if html.immutable {
|
||||
value.push_str("immutable, ");
|
||||
}
|
||||
|
||||
value.push_str("public, ");
|
||||
value.push_str(&format!("max-age={}, ", max_age));
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
html.headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_str(value.trim().trim_end_matches(',')).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
if !html.headers.contains_key("Accept-CH") {
|
||||
html.headers
|
||||
.insert("Accept-CH", HeaderValue::from_static("Sec-CH-UA-Mobile"));
|
||||
}
|
||||
|
||||
if let Some(mime) = &html.mime {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
html.headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(&mime.to_string()).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
trace!(
|
||||
message = "Served route",
|
||||
route,
|
||||
addr = ?addr.addr,
|
||||
user_agent = ua,
|
||||
device_type = ?client_info.device_type,
|
||||
cached,
|
||||
time_ns = start.elapsed().as_nanos()
|
||||
);
|
||||
|
||||
return match html.body {
|
||||
RenderedBody::Markup(markup) => (html.code, html.headers, markup.0).into_response(),
|
||||
RenderedBody::Static(data) => (html.code, html.headers, data).into_response(),
|
||||
RenderedBody::Bytes(data) => (html.code, html.headers, data).into_response(),
|
||||
RenderedBody::String(s) => (html.code, html.headers, s).into_response(),
|
||||
RenderedBody::Empty => (html.code, html.headers).into_response(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn into_router(self: Arc<Self>) -> Router<()> {
|
||||
let compression: CompressionLayer = CompressionLayer::new()
|
||||
.br(true)
|
||||
.deflate(true)
|
||||
.gzip(true)
|
||||
.zstd(true)
|
||||
.compress_when(DefaultPredicate::new());
|
||||
|
||||
Router::new()
|
||||
.route(
|
||||
"/",
|
||||
get(|state, query, conn, headers| async {
|
||||
Self::handler(Path(String::new()), query, state, conn, headers).await
|
||||
}),
|
||||
)
|
||||
.route("/{*path}", get(Self::handler))
|
||||
.layer(compression)
|
||||
.with_state(self)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,66 @@
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use chrono::TimeDelta;
|
||||
use maud::Markup;
|
||||
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)]
|
||||
pub struct RequestContext {
|
||||
pub struct RenderContext {
|
||||
pub client_info: ClientInfo,
|
||||
pub route: String,
|
||||
pub query: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
// MARK: clientinfo
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -25,10 +75,6 @@ impl Default for DeviceType {
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: clientinfo
|
||||
//
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ClientInfo {
|
||||
/// This is an estimate, but it's probably good enough.
|
||||
@@ -38,6 +38,20 @@ impl TransformerChain {
|
||||
return image;
|
||||
}
|
||||
|
||||
pub fn output_mime(&self, input_mime: &MimeType) -> Option<MimeType> {
|
||||
let mime = self
|
||||
.steps
|
||||
.last()
|
||||
.and_then(|x| match x {
|
||||
TransformerEnum::Format { format } => Some(MimeType::from(format.to_mime_type())),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(input_mime.clone());
|
||||
|
||||
let fmt = ImageFormat::from_mime_type(mime.to_string());
|
||||
fmt.map(|_| mime)
|
||||
}
|
||||
|
||||
pub fn transform_bytes(
|
||||
&self,
|
||||
image_bytes: &[u8],
|
||||
|
||||
@@ -251,6 +251,23 @@ impl From<&MimeType> for String {
|
||||
// MARK: fromstr
|
||||
//
|
||||
|
||||
impl MimeType {
|
||||
/// Parse a mimetype from a string that may contain
|
||||
/// whitespace or ";" parameters.
|
||||
///
|
||||
/// Parameters are discarded, write your own parser if you need them.
|
||||
pub fn from_header(s: &str) -> Result<Self, <Self as FromStr>::Err> {
|
||||
let s = s.trim();
|
||||
let semi = s.find(';').unwrap_or(s.len());
|
||||
let space = s.find(' ').unwrap_or(s.len());
|
||||
let limit = semi.min(space);
|
||||
let s = &s[0..limit];
|
||||
let s = s.trim();
|
||||
|
||||
return Self::from_str(s);
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for MimeType {
|
||||
type Err = std::convert::Infallible;
|
||||
|
||||
@@ -687,4 +704,108 @@ impl MimeType {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,3 +28,4 @@ toml = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use lazy_static::lazy_static;
|
||||
use markdown_it::generics::inline::full_link;
|
||||
use markdown_it::{MarkdownIt, Node};
|
||||
use maud::{Markup, PreEscaped, Render, html};
|
||||
use page::RequestContext;
|
||||
use page::page::{Page, PageMetadata};
|
||||
use maud::{Markup, PreEscaped, Render};
|
||||
use page::servable::PageMetadata;
|
||||
|
||||
use crate::components::md::emote::InlineEmote;
|
||||
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.
|
||||
/// - returns `none` if there is no 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_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) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,18 +6,17 @@ use std::{
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
use page::{DeviceType, RequestContext, page::Page};
|
||||
use page::{DeviceType, RenderContext, servable::Page};
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::{
|
||||
components::{
|
||||
md::{Markdown, backlinks, meta_from_markdown},
|
||||
md::{Markdown, meta_from_markdown},
|
||||
misc::FarLink,
|
||||
},
|
||||
pages::page_wrapper,
|
||||
pages::{MAIN_TEMPLATE, backlinks, footer},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -114,11 +113,7 @@ async fn get_index() -> Result<Vec<HandoutEntry>, reqwest::Error> {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
fn build_list_for_group(
|
||||
handouts: &[HandoutEntry],
|
||||
group: &str,
|
||||
req_ctx: &RequestContext,
|
||||
) -> Markup {
|
||||
fn build_list_for_group(handouts: &[HandoutEntry], group: &str, req_ctx: &RenderContext) -> Markup {
|
||||
let mobile = req_ctx.client_info.device_type == DeviceType::Mobile;
|
||||
|
||||
if mobile {
|
||||
@@ -198,74 +193,79 @@ pub fn handouts() -> Page {
|
||||
|
||||
let html = PreEscaped(md.render());
|
||||
|
||||
Page {
|
||||
meta,
|
||||
html_ttl: Some(TimeDelta::seconds(300)),
|
||||
immutable: false,
|
||||
response_code: StatusCode::OK,
|
||||
|
||||
generate_html: Box::new(move |page, ctx| {
|
||||
let html = html.clone(); // TODO: find a way to not clone here
|
||||
MAIN_TEMPLATE
|
||||
.derive(meta, move |page, ctx| {
|
||||
let html = html.clone();
|
||||
let index = index.clone();
|
||||
Box::pin(async move {
|
||||
let handouts = index.get().await;
|
||||
|
||||
let fallback = html! {
|
||||
span style="color:var(--yellow)" {
|
||||
"Could not load handouts, something broke."
|
||||
}
|
||||
" "
|
||||
(
|
||||
FarLink(
|
||||
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
|
||||
"Try this direct link."
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
let warmups = match &*handouts {
|
||||
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", ctx),
|
||||
Err(error) => {
|
||||
warn!("Could not load handout index: {error:?}");
|
||||
fallback.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let advanced = match &*handouts {
|
||||
Ok(handouts) => build_list_for_group(handouts, "Advanced", ctx),
|
||||
Err(_) => fallback,
|
||||
};
|
||||
|
||||
let inner = html! {
|
||||
@if let Some(backlinks) = backlinks(page, ctx) {
|
||||
(backlinks)
|
||||
}
|
||||
|
||||
(html)
|
||||
|
||||
(Markdown(concat!(
|
||||
"## Warm-Ups",
|
||||
"\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, ",
|
||||
"giving them something to do until we can start the lesson.",
|
||||
)))
|
||||
(warmups)
|
||||
br {}
|
||||
|
||||
(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 {}
|
||||
};
|
||||
|
||||
page_wrapper(&page.meta, inner, true).await
|
||||
})
|
||||
}),
|
||||
}
|
||||
render(html, index, page, ctx)
|
||||
})
|
||||
.html_ttl(Some(TimeDelta::seconds(300)))
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
html: Markup,
|
||||
index: Arc<CachedRequest<Result<Vec<HandoutEntry>, reqwest::Error>>>,
|
||||
_page: &'a Page,
|
||||
ctx: &'a RenderContext,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + Send + Sync + 'a>> {
|
||||
Box::pin(async move {
|
||||
let handouts = index.get().await;
|
||||
|
||||
let fallback = html! {
|
||||
span style="color:var(--yellow)" {
|
||||
"Could not load handouts, something broke."
|
||||
}
|
||||
" "
|
||||
(
|
||||
FarLink(
|
||||
"https://git.betalupi.com/Mark/-/packages/generic/ormc-handouts/latest",
|
||||
"Try this direct link."
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
let warmups = match &*handouts {
|
||||
Ok(handouts) => build_list_for_group(handouts, "Warm-Ups", ctx),
|
||||
Err(error) => {
|
||||
warn!("Could not load handout index: {error:?}");
|
||||
fallback.clone()
|
||||
}
|
||||
};
|
||||
|
||||
let advanced = match &*handouts {
|
||||
Ok(handouts) => build_list_for_group(handouts, "Advanced", ctx),
|
||||
Err(_) => fallback,
|
||||
};
|
||||
|
||||
html! {
|
||||
div class="wrapper" style="margin-top:3ex;" {
|
||||
@if let Some(backlinks) = backlinks(ctx) {
|
||||
(backlinks)
|
||||
}
|
||||
|
||||
(html)
|
||||
|
||||
(Markdown(concat!(
|
||||
"## Warm-Ups",
|
||||
"\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, ",
|
||||
"giving them something to do until we can start the lesson.",
|
||||
)))
|
||||
(warmups)
|
||||
br {}
|
||||
|
||||
(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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use maud::html;
|
||||
use page::page::{Page, PageMetadata};
|
||||
use maud::{Markup, html};
|
||||
use page::{
|
||||
RenderContext,
|
||||
servable::{Page, PageMetadata},
|
||||
};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::{
|
||||
components::{
|
||||
@@ -8,76 +12,78 @@ use crate::{
|
||||
md::Markdown,
|
||||
misc::FarLink,
|
||||
},
|
||||
pages::page_wrapper,
|
||||
pages::{MAIN_TEMPLATE, footer},
|
||||
};
|
||||
|
||||
pub fn index() -> Page {
|
||||
Page {
|
||||
meta: PageMetadata {
|
||||
MAIN_TEMPLATE.derive(
|
||||
PageMetadata {
|
||||
title: "Betalupi: About".into(),
|
||||
author: Some("Mark".into()),
|
||||
description: None,
|
||||
image: Some("/assets/img/icon.png".to_owned()),
|
||||
backlinks: Some(false),
|
||||
},
|
||||
render,
|
||||
)
|
||||
}
|
||||
|
||||
generate_html: Box::new(move |page, _ctx| {
|
||||
Box::pin(async {
|
||||
let inner = html! {
|
||||
h2 id="about" { "About" }
|
||||
fn render<'a>(
|
||||
_page: &'a Page,
|
||||
_ctx: &'a RenderContext,
|
||||
) -> 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
|
||||
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%;" {
|
||||
"Welcome, you've reached Mark's main page. Here you'll find"
|
||||
" links to various projects I've worked on."
|
||||
|
||||
div style="margin:2ex 1ex 2ex 1ex;display:inline-block;overflow:hidden;width:60%;" {
|
||||
"Welcome, you've reached Mark's main page. Here you'll find"
|
||||
" links to various projects I've worked on."
|
||||
ul {
|
||||
li { (MangledBetaEmail {}) }
|
||||
li { (MangledGoogleEmail {}) }
|
||||
|
||||
ul {
|
||||
li { (MangledBetaEmail {}) }
|
||||
li { (MangledGoogleEmail {}) }
|
||||
|
||||
li {
|
||||
(
|
||||
FarLink(
|
||||
"https://github.com/rm-dr",
|
||||
html!(
|
||||
(FAIcon::Github)
|
||||
"rm-dr"
|
||||
)
|
||||
li {
|
||||
(
|
||||
FarLink(
|
||||
"https://github.com/rm-dr",
|
||||
html!(
|
||||
(FAIcon::Github)
|
||||
"rm-dr"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
li {
|
||||
(
|
||||
FarLink(
|
||||
"https://git.betalupi.com",
|
||||
html!(
|
||||
(FAIcon::Git)
|
||||
"git.betalupi.com"
|
||||
)
|
||||
li {
|
||||
(
|
||||
FarLink(
|
||||
"https://git.betalupi.com",
|
||||
html!(
|
||||
(FAIcon::Git)
|
||||
"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, true).await
|
||||
})
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
(footer())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use chrono::TimeDelta;
|
||||
use maud::{DOCTYPE, Markup, PreEscaped, html};
|
||||
use page::page::{Page, PageMetadata};
|
||||
use reqwest::StatusCode;
|
||||
use std::pin::Pin;
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
use page::{
|
||||
RenderContext,
|
||||
servable::{Page, PageMetadata, PageTemplate},
|
||||
};
|
||||
|
||||
use crate::components::{
|
||||
fa::FAIcon,
|
||||
md::{Markdown, backlinks, meta_from_markdown},
|
||||
md::{Markdown, meta_from_markdown},
|
||||
misc::FarLink,
|
||||
};
|
||||
|
||||
@@ -67,120 +68,119 @@ fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> P
|
||||
|
||||
let html = PreEscaped(md.render());
|
||||
|
||||
Page {
|
||||
meta,
|
||||
immutable: true,
|
||||
response_code: StatusCode::OK,
|
||||
|
||||
html_ttl: Some(TimeDelta::days(1)),
|
||||
generate_html: Box::new(move |page, ctx| {
|
||||
MAIN_TEMPLATE
|
||||
.derive(meta, move |_page, ctx| {
|
||||
let html = html.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let inner = html! {
|
||||
@if let Some(backlinks) = backlinks(page, ctx) {
|
||||
(backlinks)
|
||||
html! {
|
||||
div class="wrapper" style="margin-top:3ex;" {
|
||||
@if let Some(backlinks) = backlinks(ctx) {
|
||||
(backlinks)
|
||||
}
|
||||
|
||||
(html)
|
||||
|
||||
(footer())
|
||||
}
|
||||
|
||||
(html)
|
||||
};
|
||||
|
||||
page_wrapper(&page.meta, inner, true).await
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
})
|
||||
.html_ttl(Some(TimeDelta::days(1)))
|
||||
.immutable(true)
|
||||
}
|
||||
|
||||
//
|
||||
// MARK: wrapper
|
||||
// MARK: components
|
||||
//
|
||||
|
||||
pub fn page_wrapper<'a>(
|
||||
meta: &'a PageMetadata,
|
||||
inner: Markup,
|
||||
footer: bool,
|
||||
) -> Pin<Box<dyn Future<Output = Markup> + 'a + Send + Sync>> {
|
||||
Box::pin(async move {
|
||||
const MAIN_TEMPLATE: PageTemplate = PageTemplate {
|
||||
// Order matters, base htmx goes first
|
||||
scripts_linked: &["/assets/htmx.js", "/assets/htmx-json.js"],
|
||||
|
||||
// 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! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
meta charset="UTF8" {}
|
||||
meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" {}
|
||||
meta content="text/html; charset=UTF-8" http-equiv="content-type" {}
|
||||
meta property="og:type" content="website" {}
|
||||
|
||||
link rel="stylesheet" href=("/assets/css/main.css") {}
|
||||
|
||||
(&meta)
|
||||
title { (PreEscaped(meta.title.clone())) }
|
||||
|
||||
|
||||
// Use a small blurred placeholder while full-size images load.
|
||||
// Requires no other special scripts or css, just add some tags
|
||||
// to your <img>!
|
||||
script {
|
||||
(PreEscaped("
|
||||
window.onload = function() {
|
||||
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)';
|
||||
};
|
||||
})
|
||||
}
|
||||
"))
|
||||
}
|
||||
div {
|
||||
@for (url, text) in backlinks {
|
||||
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
|
||||
"/"
|
||||
}
|
||||
|
||||
body {
|
||||
main{
|
||||
div class="wrapper" style=(
|
||||
// for 404 page. Margin makes it scroll.
|
||||
match footer {
|
||||
true => "margin-top:3ex;",
|
||||
false =>""
|
||||
}
|
||||
) {
|
||||
(inner)
|
||||
|
||||
@if footer {
|
||||
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!"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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!"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
use maud::html;
|
||||
use page::page::{Page, PageMetadata};
|
||||
use page::servable::{Page, PageMetadata};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::pages::page_wrapper;
|
||||
use crate::pages::MAIN_TEMPLATE;
|
||||
|
||||
pub fn notfound() -> Page {
|
||||
Page {
|
||||
meta: PageMetadata {
|
||||
title: "Betalupi: About".into(),
|
||||
author: None,
|
||||
MAIN_TEMPLATE.derive(
|
||||
PageMetadata {
|
||||
title: "Page not found".into(),
|
||||
author:None,
|
||||
description: None,
|
||||
image: Some("/assets/img/icon.png".to_owned()),
|
||||
backlinks: Some(false),
|
||||
},
|
||||
|
||||
response_code: StatusCode::NOT_FOUND,
|
||||
generate_html: Box::new(move |page, _ctx| {
|
||||
move |_page, _ctx| {
|
||||
Box::pin(async {
|
||||
let inner = html! {
|
||||
div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh" {
|
||||
p style="font-weight:bold;font-size:50pt;margin:0;" { "404" }
|
||||
p style="font-size:13pt;margin:0;color:var(--grey);" { "(page not found)" }
|
||||
a style="font-size:12pt;margin:10pt;padding:5px;" href="/" {"<- Back to site"}
|
||||
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"}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
page_wrapper(&page.meta, inner, false).await
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
).response_code(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
use axum::Router;
|
||||
use macro_sass::sass;
|
||||
use page::{PageServer, asset::StaticAsset, redirect::Redirect};
|
||||
use std::sync::Arc;
|
||||
use page::{
|
||||
ServableRoute,
|
||||
servable::{Redirect, StaticAsset},
|
||||
};
|
||||
use toolbox::mime::MimeType;
|
||||
use tower_http::compression::{CompressionLayer, DefaultPredicate};
|
||||
|
||||
use crate::pages;
|
||||
|
||||
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> {
|
||||
let server = PageServer::new();
|
||||
|
||||
#[expect(clippy::unwrap_used)]
|
||||
server
|
||||
fn build_server() -> ServableRoute {
|
||||
ServableRoute::new()
|
||||
.with_404(pages::notfound())
|
||||
.add_page("/", pages::index())
|
||||
.add_page("/links", pages::links())
|
||||
.add_page("/whats-a-betalupi", pages::betalupi())
|
||||
.add_page("/handouts", pages::handouts())
|
||||
.add_page("/htwah", Redirect::new("/handouts").unwrap())
|
||||
.add_page("/htwah", {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
Redirect::new("/handouts").unwrap()
|
||||
})
|
||||
.add_page("/htwah/typesetting", pages::htwah_typesetting())
|
||||
.add_page("/assets/htmx.js", page::HTMX_2_0_8)
|
||||
.add_page("/assets/htmx-json.js", page::EXT_JSON_1_19_12)
|
||||
//
|
||||
.add_page(
|
||||
"/assets/css/main.css",
|
||||
@@ -185,9 +197,7 @@ fn build_server() -> Arc<PageServer> {
|
||||
bytes: include_bytes!("../../assets/htwah/spacing-b.pdf"),
|
||||
mime: MimeType::Pdf,
|
||||
},
|
||||
);
|
||||
|
||||
server
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,7 +207,6 @@ fn server_builds_without_panic() {
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
// Needs tokio context
|
||||
let _server = build_server().into_router();
|
||||
let _server = build_server();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,4 +6,4 @@ extend-ignore-re = [
|
||||
]
|
||||
|
||||
[files]
|
||||
extend-exclude = ["crates/service/service-webpage/css"]
|
||||
extend-exclude = ["crates/service/service-webpage/css", "crates/lib/page/htmx"]
|
||||
|
||||
Reference in New Issue
Block a user