Compare commits
No commits in common. "5193fc2be0f58583d720bc721e8b042c69983719" and "86b4bf5b2ddb38f3b5fdf362da3c3e75704bfd48" have entirely different histories.
5193fc2be0
...
86b4bf5b2d
457
Cargo.lock
generated
457
Cargo.lock
generated
|
@ -1,6 +1,6 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
|
@ -26,12 +26,6 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned-vec"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
|
||||
|
||||
[[package]]
|
||||
name = "android-tzdata"
|
||||
version = "0.1.1"
|
||||
|
@ -102,23 +96,6 @@ version = "1.0.86"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
|
||||
|
||||
[[package]]
|
||||
name = "arg_enum_proc_macro"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.7"
|
||||
|
@ -127,9 +104,9 @@ checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
|
|||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
|
@ -148,29 +125,6 @@ version = "1.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
|
||||
|
||||
[[package]]
|
||||
name = "av1-grain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "avif-serialize"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.5"
|
||||
|
@ -265,12 +219,6 @@ version = "2.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.5.3"
|
||||
|
@ -293,12 +241,6 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c360505aed52b7ec96a3636c3f039d99103c37d1d9b4f7a8c743d3ea9ffcd03b"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
|
@ -312,10 +254,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
|
@ -334,16 +276,6 @@ dependencies = [
|
|||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
|
@ -506,20 +438,6 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
|
@ -863,43 +781,22 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.5"
|
||||
version = "0.24.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b"
|
||||
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"byteorder",
|
||||
"color_quant",
|
||||
"exr",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"jpeg-decoder",
|
||||
"num-traits",
|
||||
"png",
|
||||
"qoi",
|
||||
"ravif",
|
||||
"rayon",
|
||||
"rgb",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e031e8e3d94711a9ccb5d6ea357439ef3dcbed361798bd4071dc4d9793fbe22f"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.2.6"
|
||||
|
@ -911,17 +808,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.12"
|
||||
|
@ -939,15 +825,6 @@ version = "1.70.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.11"
|
||||
|
@ -974,6 +851,9 @@ name = "jpeg-decoder"
|
|||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
|
@ -996,16 +876,6 @@ version = "0.2.155"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b9569d2f74e257076d8c6bfa73fb505b46b851e51ddaecc825944aa3bed17fa"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
version = "0.17.0+1.8.1"
|
||||
|
@ -1046,31 +916,12 @@ version = "0.4.21"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
|
||||
|
||||
[[package]]
|
||||
name = "loop9"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||
dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-rayon"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.2"
|
||||
|
@ -1083,12 +934,6 @@ version = "0.3.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.7.3"
|
||||
|
@ -1110,69 +955,6 @@ dependencies = [
|
|||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "noop_proc_macro"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
|
@ -1230,12 +1012,6 @@ dependencies = [
|
|||
"windows-targets 0.52.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.1"
|
||||
|
@ -1354,22 +1130,14 @@ dependencies = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling"
|
||||
version = "1.0.16"
|
||||
name = "pulldown-cmark"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d"
|
||||
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
|
||||
dependencies = [
|
||||
"profiling-procmacros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "profiling-procmacros"
|
||||
version = "1.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
"bitflags 2.5.0",
|
||||
"memchr",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1381,12 +1149,6 @@ dependencies = [
|
|||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.36"
|
||||
|
@ -1426,56 +1188,6 @@ dependencies = [
|
|||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rav1e"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"arg_enum_proc_macro",
|
||||
"arrayvec",
|
||||
"av1-grain",
|
||||
"bitstream-io",
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
"maybe-rayon",
|
||||
"new_debug_unreachable",
|
||||
"noop_proc_macro",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"simd_helpers",
|
||||
"system-deps",
|
||||
"thiserror",
|
||||
"v_frame",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ravif"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6"
|
||||
dependencies = [
|
||||
"avif-serialize",
|
||||
"imgref",
|
||||
"loop9",
|
||||
"quick-error",
|
||||
"rav1e",
|
||||
"rayon",
|
||||
"rgb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.10.0"
|
||||
|
@ -1534,12 +1246,6 @@ version = "0.8.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
|
@ -1667,15 +1373,6 @@ version = "0.3.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.13.2"
|
||||
|
@ -1730,25 +1427,6 @@ version = "1.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394"
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
|
@ -1834,18 +1512,6 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2caab0bf757388c6c0ae23b3293fdb463fee59434529014f85e3263b995c28"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit 0.22.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.6"
|
||||
|
@ -1865,20 +1531,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"winnow 0.6.20",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1903,6 +1556,20 @@ version = "0.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
|
||||
|
||||
[[package]]
|
||||
name = "tower-livereload"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61d6cbbab4b2d3cafd21fb211cc4b06525a0df919c3e8ca3d36485b1c1bd4cd4"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.2"
|
||||
|
@ -1941,7 +1608,6 @@ dependencies = [
|
|||
"clap",
|
||||
"codespan-reporting",
|
||||
"copy_dir",
|
||||
"dashmap",
|
||||
"env_logger",
|
||||
"git2",
|
||||
"handlebars",
|
||||
|
@ -1950,13 +1616,14 @@ dependencies = [
|
|||
"indexmap",
|
||||
"jotdown",
|
||||
"log",
|
||||
"pulldown-cmark",
|
||||
"rand",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"toml_edit 0.19.15",
|
||||
"toml_edit",
|
||||
"tower-livereload",
|
||||
"treehouse-format",
|
||||
"ulid",
|
||||
"url",
|
||||
|
@ -1993,6 +1660,15 @@ dependencies = [
|
|||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89"
|
||||
dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.15"
|
||||
|
@ -2037,29 +1713,12 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
|
||||
|
||||
[[package]]
|
||||
name = "v_frame"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b"
|
||||
dependencies = [
|
||||
"aligned-vec",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.4"
|
||||
|
@ -2318,21 +1977,6 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||
|
||||
[[package]]
|
||||
name = "zune-inflate"
|
||||
version = "0.2.54"
|
||||
|
@ -2341,12 +1985,3 @@ checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
|||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16099418600b4d8f028622f73ff6e3deaabdff330fb9a2a131dea781ee8b0768"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
%% title = "404"
|
||||
|
||||
% id = "01HMF8KQ997F1ZTEGDNAE2S6F1"
|
||||
- seems like the page you're looking for isn't here.
|
||||
% id = "404"
|
||||
- # 404
|
||||
|
||||
% id = "01HMF8KQ99XNMEP67NE3QH5698"
|
||||
- care to go [back to the index][branch:treehouse]?
|
||||
% id = "01HMF8KQ997F1ZTEGDNAE2S6F1"
|
||||
- seems like the page you're looking for isn't here.
|
||||
|
||||
% id = "01HMF8KQ99XNMEP67NE3QH5698"
|
||||
- care to go [back to the index][branch:treehouse]?
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<style>
|
||||
@font-face {
|
||||
font-family: "Determination Sans";
|
||||
src: url('/static/font/DTM-Sans.otf?v=b3-4fe96c14');
|
||||
src: url('/static/font/DTM-Sans.otf?cache=b3-4fe96c14');
|
||||
}
|
||||
|
||||
.undertale-save-box {
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
%% title = "treehouse virtual file system design"
|
||||
|
||||
- notes on the design; this is not an actual listing of the virtual file system
|
||||
|
||||
- `content` - `GitDir(".", "content")`
|
||||
|
||||
- `GitDir` is a special filesystem which makes all files have subpaths with commit data sourced from git.
|
||||
their entries are ordered by how new/old a commit is
|
||||
|
||||
- `inner/<commit>` - contains the file content and a revision info fork
|
||||
|
||||
- `inner/latest` - same but for the latest revision, if applicable.
|
||||
this may be the working tree
|
||||
|
||||
- `template` - `PhysicalDir("template")`
|
||||
|
||||
- `static` - `PhysicalDir("static")`
|
|
@ -46,7 +46,7 @@ enum AllowCodeBlocks {
|
|||
Yes,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
impl<'a> Parser<'a> {
|
||||
fn current(&self) -> Option<char> {
|
||||
self.input[self.position..].chars().next()
|
||||
}
|
||||
|
|
|
@ -15,22 +15,24 @@ chrono = "0.4.35"
|
|||
clap = { version = "4.3.22", features = ["derive"] }
|
||||
codespan-reporting = "0.11.1"
|
||||
copy_dir = "0.1.3"
|
||||
dashmap = "6.1.0"
|
||||
env_logger = "0.10.0"
|
||||
git2 = { version = "0.19.0", default-features = false, features = ["vendored-libgit2"] }
|
||||
handlebars = "4.3.7"
|
||||
http-body = "1.0.0"
|
||||
image = "0.25.5"
|
||||
image = "0.24.8"
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
jotdown = { version = "0.4.1", default-features = false }
|
||||
log = { workspace = true }
|
||||
rand = "0.8.5"
|
||||
rayon = "1.10.0"
|
||||
regex = "1.10.3"
|
||||
serde = { version = "1.0.183", features = ["derive"] }
|
||||
serde_json = "1.0.105"
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
toml_edit = { version = "0.19.14", features = ["serde"] }
|
||||
tower-livereload = "0.9.2"
|
||||
walkdir = "2.3.3"
|
||||
ulid = "1.0.0"
|
||||
url = "2.5.0"
|
||||
|
||||
# TODO djot: To remove once migration to Djot is complete.
|
||||
pulldown-cmark = { version = "0.9.3", default-features = false }
|
||||
|
|
|
@ -2,9 +2,9 @@ pub mod fix;
|
|||
pub mod serve;
|
||||
pub mod wc;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::vfs::VPathBuf;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct ProgramArgs {
|
||||
|
@ -14,6 +14,9 @@ pub struct ProgramArgs {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Command {
|
||||
/// Regenerate the website.
|
||||
Generate(#[clap(flatten)] GenerateArgs),
|
||||
|
||||
/// Populate missing metadata in blocks.
|
||||
Fix(#[clap(flatten)] FixArgs),
|
||||
|
||||
|
@ -41,13 +44,20 @@ pub enum Command {
|
|||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct GenerateArgs {}
|
||||
pub struct GenerateArgs {
|
||||
/// Only use commits as sources. This will cause the latest revision to be taken from the
|
||||
/// Git history instead of the working tree.
|
||||
///
|
||||
/// Recommended for deployment.
|
||||
#[clap(long)]
|
||||
pub commits_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct FixArgs {
|
||||
/// Which file to fix. The fixed file will be printed into stdout so that you have a chance to
|
||||
/// see the changes.
|
||||
pub file: VPathBuf,
|
||||
pub file: PathBuf,
|
||||
|
||||
/// If you're happy with the suggested changes, specifying this will apply them to the file
|
||||
/// (overwrite it in place.)
|
||||
|
@ -56,7 +66,7 @@ pub struct FixArgs {
|
|||
|
||||
/// Write the previous version back to the specified path.
|
||||
#[clap(long)]
|
||||
pub backup: Option<VPathBuf>,
|
||||
pub backup: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
@ -78,5 +88,17 @@ pub struct ServeArgs {
|
|||
pub struct WcArgs {
|
||||
/// A list of paths to report the word counts of.
|
||||
/// If no paths are provided, the entire tree is word-counted.
|
||||
pub paths: Vec<VPathBuf>,
|
||||
pub paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Paths<'a> {
|
||||
pub target_dir: &'a Path,
|
||||
pub template_target_dir: &'a Path,
|
||||
|
||||
pub static_dir: &'a Path,
|
||||
pub template_dir: &'a Path,
|
||||
pub content_dir: &'a Path,
|
||||
|
||||
pub config_file: &'a Path,
|
||||
}
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
use std::ops::{ControlFlow, Range};
|
||||
use std::{ffi::OsStr, ops::Range};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use anyhow::Context;
|
||||
use codespan_reporting::diagnostic::Diagnostic;
|
||||
use log::{error, info};
|
||||
use treehouse_format::ast::Branch;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{
|
||||
parse::{self, parse_toml_with_diagnostics, parse_tree_with_diagnostics},
|
||||
state::{report_diagnostics, FileId, Source, Treehouse},
|
||||
vfs::{self, Dir, Edit, VPath},
|
||||
};
|
||||
|
||||
use super::{FixAllArgs, FixArgs};
|
||||
use super::{FixAllArgs, FixArgs, Paths};
|
||||
|
||||
struct Fix {
|
||||
range: Range<usize>,
|
||||
|
@ -133,102 +132,68 @@ pub fn fix_file(
|
|||
})
|
||||
}
|
||||
|
||||
pub fn fix_file_cli(fix_args: FixArgs, root: &dyn Dir) -> anyhow::Result<Edit> {
|
||||
let file = if &*fix_args.file == VPath::new("-") {
|
||||
pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> {
|
||||
let utf8_filename = fix_args.file.to_string_lossy().into_owned();
|
||||
let file = if utf8_filename == "-" {
|
||||
std::io::read_to_string(std::io::stdin().lock()).context("cannot read file from stdin")?
|
||||
} else {
|
||||
String::from_utf8(
|
||||
root.content(&fix_args.file)
|
||||
.ok_or_else(|| anyhow!("cannot read file to fix"))?,
|
||||
)
|
||||
.context("input file has invalid UTF-8")?
|
||||
std::fs::read_to_string(&fix_args.file).context("cannot read file to fix")?
|
||||
};
|
||||
|
||||
let mut treehouse = Treehouse::new();
|
||||
let mut diagnostics = vec![];
|
||||
let file_id = treehouse.add_file(fix_args.file.as_str().to_owned(), Source::Other(file));
|
||||
let edit_path = root.edit_path(&fix_args.file).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"{} is not an editable file (perhaps it is not in a persistent path?)",
|
||||
fix_args.file
|
||||
)
|
||||
})?;
|
||||
let file_id = treehouse.add_file(utf8_filename, Source::Other(file));
|
||||
|
||||
Ok(
|
||||
if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) {
|
||||
if fix_args.apply {
|
||||
// Try to write the backup first. If writing that fails, bail out without overwriting
|
||||
// the source file.
|
||||
if let Some(backup_path) = fix_args.backup {
|
||||
let backup_edit_path = root.edit_path(&backup_path).ok_or_else(|| {
|
||||
anyhow!("backup file {backup_path} is not an editable file")
|
||||
})?;
|
||||
Edit::Seq(vec![
|
||||
Edit::Write(
|
||||
backup_edit_path,
|
||||
treehouse.source(file_id).input().to_owned(),
|
||||
),
|
||||
Edit::Write(edit_path, fixed),
|
||||
])
|
||||
} else {
|
||||
Edit::Write(edit_path, fixed)
|
||||
}
|
||||
} else {
|
||||
println!("{fixed}");
|
||||
Edit::NoOp
|
||||
if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) {
|
||||
if fix_args.apply {
|
||||
// Try to write the backup first. If writing that fails, bail out without overwriting
|
||||
// the source file.
|
||||
if let Some(backup_path) = fix_args.backup {
|
||||
std::fs::write(backup_path, treehouse.source(file_id).input())
|
||||
.context("cannot write backup; original file will not be overwritten")?;
|
||||
}
|
||||
std::fs::write(&fix_args.file, fixed).context("cannot overwrite original file")?;
|
||||
} else {
|
||||
report_diagnostics(&treehouse.files, &diagnostics)?;
|
||||
Edit::NoOp
|
||||
},
|
||||
)
|
||||
println!("{fixed}");
|
||||
}
|
||||
} else {
|
||||
report_diagnostics(&treehouse.files, &diagnostics)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn fix_all_cli(fix_all_args: FixAllArgs, dir: &dyn Dir) -> anyhow::Result<Edit> {
|
||||
let mut edits = vec![];
|
||||
|
||||
fn fix_one(dir: &dyn Dir, path: &VPath) -> anyhow::Result<Edit> {
|
||||
if path.extension() == Some("tree") {
|
||||
let Some(content) = dir.content(path) else {
|
||||
return Ok(Edit::NoOp);
|
||||
};
|
||||
let content = String::from_utf8(content).context("file is not valid UTF-8")?;
|
||||
pub fn fix_all_cli(fix_all_args: FixAllArgs, paths: &Paths<'_>) -> anyhow::Result<()> {
|
||||
for entry in WalkDir::new(paths.content_dir) {
|
||||
let entry = entry?;
|
||||
if entry.file_type().is_file() && entry.path().extension() == Some(OsStr::new("tree")) {
|
||||
let file = std::fs::read_to_string(entry.path())
|
||||
.with_context(|| format!("cannot read file to fix: {:?}", entry.path()))?;
|
||||
let utf8_filename = entry.path().to_string_lossy();
|
||||
|
||||
let mut treehouse = Treehouse::new();
|
||||
let mut diagnostics = vec![];
|
||||
let file_id = treehouse.add_file(path.as_str().to_string(), Source::Other(content));
|
||||
let edit_path = dir.edit_path(path).context("path is not editable")?;
|
||||
let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file));
|
||||
|
||||
if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) {
|
||||
if fixed != treehouse.source(file_id).input() {
|
||||
return Ok(Edit::Write(edit_path, fixed));
|
||||
if fix_all_args.apply {
|
||||
println!("fixing: {:?}", entry.path());
|
||||
std::fs::write(entry.path(), fixed).with_context(|| {
|
||||
format!("cannot overwrite original file: {:?}", entry.path())
|
||||
})?;
|
||||
} else {
|
||||
println!("will fix: {:?}", entry.path());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
report_diagnostics(&treehouse.files, &diagnostics)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Edit::NoOp)
|
||||
}
|
||||
|
||||
info!("gathering edits");
|
||||
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
|
||||
match fix_one(dir, path) {
|
||||
Ok(Edit::NoOp) => (),
|
||||
Ok(edit) => edits.push(edit),
|
||||
Err(err) => error!("cannot fix {path}: {err:?}"),
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
|
||||
// NOTE: This number may be higher than you expect, because NoOp edits also count!
|
||||
info!("{} edits to apply", edits.len());
|
||||
|
||||
if !fix_all_args.apply {
|
||||
info!("dry run; add `--apply` to apply changes");
|
||||
Ok(Edit::Dry(Box::new(Edit::All(edits))))
|
||||
} else {
|
||||
Ok(Edit::All(edits))
|
||||
println!("run with `--apply` to apply changes");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,182 +1,227 @@
|
|||
#[cfg(debug_assertions)]
|
||||
mod live_reload;
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::{net::Ipv4Addr, sync::Arc};
|
||||
use std::{net::Ipv4Addr, path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::{Path, Query, RawQuery, State},
|
||||
http::{
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE},
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION},
|
||||
HeaderValue, StatusCode,
|
||||
},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use log::info;
|
||||
use log::{error, info};
|
||||
use pulldown_cmark::escape::escape_html;
|
||||
use serde::Deserialize;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::generate::Sources;
|
||||
use crate::vfs::asynch::AsyncDir;
|
||||
use crate::vfs::VPath;
|
||||
use crate::{html::EscapeHtml, state::Source};
|
||||
use crate::{
|
||||
config::Config,
|
||||
state::{Source, Treehouse},
|
||||
};
|
||||
|
||||
mod system {
|
||||
use crate::vfs::VPath;
|
||||
use super::Paths;
|
||||
|
||||
pub const INDEX: &VPath = VPath::new_const("index");
|
||||
pub const FOUR_OH_FOUR: &VPath = VPath::new_const("_treehouse/404");
|
||||
pub const B_DOCS: &VPath = VPath::new_const("_treehouse/b");
|
||||
struct SystemPages {
|
||||
index: String,
|
||||
four_oh_four: String,
|
||||
b_docs: String,
|
||||
sandbox: String,
|
||||
|
||||
navmap: String,
|
||||
}
|
||||
|
||||
struct Server {
|
||||
sources: Arc<Sources>,
|
||||
target: AsyncDir,
|
||||
config: Config,
|
||||
treehouse: Treehouse,
|
||||
target_dir: PathBuf,
|
||||
system_pages: SystemPages,
|
||||
}
|
||||
|
||||
pub async fn serve(sources: Arc<Sources>, target: AsyncDir, port: u16) -> anyhow::Result<()> {
|
||||
pub async fn serve(
|
||||
config: Config,
|
||||
treehouse: Treehouse,
|
||||
paths: &Paths<'_>,
|
||||
port: u16,
|
||||
) -> anyhow::Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/", get(index)) // needed explicitly because * does not match empty paths
|
||||
.route("/*path", get(vfs_entry))
|
||||
.route("/", get(index))
|
||||
.route("/*page", get(page))
|
||||
.route("/b", get(branch))
|
||||
.route("/navmap.js", get(navmap))
|
||||
.route("/sandbox", get(sandbox))
|
||||
.route("/static/*file", get(static_file))
|
||||
.fallback(get(four_oh_four))
|
||||
.with_state(Arc::new(Server { sources, target }));
|
||||
.with_state(Arc::new(Server {
|
||||
config,
|
||||
treehouse,
|
||||
target_dir: paths.target_dir.to_owned(),
|
||||
system_pages: SystemPages {
|
||||
index: std::fs::read_to_string(paths.target_dir.join("index.html"))
|
||||
.context("cannot read index page")?,
|
||||
four_oh_four: std::fs::read_to_string(paths.target_dir.join("_treehouse/404.html"))
|
||||
.context("cannot read 404 page")?,
|
||||
b_docs: std::fs::read_to_string(paths.target_dir.join("_treehouse/b.html"))
|
||||
.context("cannot read /b documentation page")?,
|
||||
sandbox: std::fs::read_to_string(paths.target_dir.join("static/html/sandbox.html"))
|
||||
.context("cannot read sandbox page")?,
|
||||
navmap: std::fs::read_to_string(paths.target_dir.join("navmap.js"))
|
||||
.context("cannot read navigation map")?,
|
||||
},
|
||||
}));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let app = app.nest("/dev/live-reload", live_reload::router());
|
||||
let app = live_reload::live_reload(app);
|
||||
|
||||
info!("serving on port {port}");
|
||||
let listener = TcpListener::bind((Ipv4Addr::from([0u8, 0, 0, 0]), port)).await?;
|
||||
Ok(axum::serve(listener, app).await?)
|
||||
}
|
||||
|
||||
fn get_content_type(extension: &str) -> Option<&'static str> {
|
||||
match extension {
|
||||
"html" => Some("text/html"),
|
||||
"js" => Some("text/javascript"),
|
||||
"woff" => Some("font/woff2"),
|
||||
"svg" => Some("image/svg+xml"),
|
||||
fn get_content_type(path: &str) -> Option<&'static str> {
|
||||
match () {
|
||||
_ if path.ends_with(".html") => Some("text/html"),
|
||||
_ if path.ends_with(".js") => Some("text/javascript"),
|
||||
_ if path.ends_with(".woff2") => Some("font/woff2"),
|
||||
_ if path.ends_with(".svg") => Some("image/svg+xml"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn index(State(state): State<Arc<Server>>) -> Response {
|
||||
Html(state.system_pages.index.clone()).into_response()
|
||||
}
|
||||
|
||||
async fn navmap(State(state): State<Arc<Server>>) -> Response {
|
||||
let mut response = state.system_pages.navmap.clone().into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("text/javascript"));
|
||||
response
|
||||
}
|
||||
|
||||
async fn four_oh_four(State(state): State<Arc<Server>>) -> Response {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(state.system_pages.four_oh_four.clone()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VfsQuery {
|
||||
#[serde(rename = "v")]
|
||||
content_version: Option<String>,
|
||||
struct StaticFileQuery {
|
||||
cache: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_static_file(path: &str, query: &VfsQuery, state: &Server) -> Option<Response> {
|
||||
let vpath = VPath::try_new(path).ok()?;
|
||||
let content = state.target.content(vpath).await?;
|
||||
let mut response = content.into_response();
|
||||
|
||||
if let Some(content_type) = vpath.extension().and_then(get_content_type) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
} else {
|
||||
response.headers_mut().remove(CONTENT_TYPE);
|
||||
}
|
||||
|
||||
if query.content_version.is_some() {
|
||||
response.headers_mut().insert(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
}
|
||||
|
||||
Some(response)
|
||||
}
|
||||
|
||||
async fn vfs_entry(
|
||||
async fn static_file(
|
||||
Path(path): Path<String>,
|
||||
Query(query): Query<VfsQuery>,
|
||||
Query(query): Query<StaticFileQuery>,
|
||||
State(state): State<Arc<Server>>,
|
||||
) -> Response {
|
||||
if let Some(response) = get_static_file(&path, &query, &state).await {
|
||||
if let Ok(file) = tokio::fs::read(state.target_dir.join("static").join(&path)).await {
|
||||
let mut response = file.into_response();
|
||||
|
||||
if let Some(content_type) = get_content_type(&path) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
} else {
|
||||
response.headers_mut().remove(CONTENT_TYPE);
|
||||
}
|
||||
|
||||
if query.cache.is_some() {
|
||||
response.headers_mut().insert(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
} else {
|
||||
four_oh_four(State(state)).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn system_page(target: &AsyncDir, path: &VPath, status_code: StatusCode) -> Response {
|
||||
if let Some(content) = target.content(path).await {
|
||||
(status_code, Html(content)).into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("500 Internal Server Error: system page {path} is not available"),
|
||||
async fn page(Path(path): Path<String>, State(state): State<Arc<Server>>) -> Response {
|
||||
let bare_path = path.strip_suffix(".html").unwrap_or(&path);
|
||||
if let Some(redirected_path) = state.config.redirects.page.get(bare_path) {
|
||||
return (
|
||||
StatusCode::MOVED_PERMANENTLY,
|
||||
[(LOCATION, format!("{}/{redirected_path}", state.config.site))],
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let html_path = format!("{bare_path}.html");
|
||||
if let Ok(file) = tokio::fs::read(state.target_dir.join(&*html_path)).await {
|
||||
([(CONTENT_TYPE, "text/html")], file).into_response()
|
||||
} else {
|
||||
four_oh_four(State(state)).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn index(State(state): State<Arc<Server>>) -> Response {
|
||||
system_page(&state.target, system::INDEX, StatusCode::OK).await
|
||||
async fn sandbox(State(state): State<Arc<Server>>) -> Response {
|
||||
// Small hack to prevent the LiveReloadLayer from injecting itself into the sandbox.
|
||||
// The sandbox is always nested under a different page, so there's no need to do that.
|
||||
let mut response = Html(state.system_pages.sandbox.clone()).into_response();
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(live_reload::DisableLiveReload);
|
||||
}
|
||||
// Debounce requests a bit. There's a tendency to have very many sandboxes on a page, and
|
||||
// loading this page as many times as there are sandboxes doesn't seem like the best way to do
|
||||
// things.
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=10"));
|
||||
response
|
||||
}
|
||||
|
||||
async fn four_oh_four(State(state): State<Arc<Server>>) -> Response {
|
||||
system_page(&state.target, system::FOUR_OH_FOUR, StatusCode::NOT_FOUND).await
|
||||
}
|
||||
|
||||
async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Response {
|
||||
async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Html<String> {
|
||||
if let Some(named_id) = named_id {
|
||||
let branch_id = state
|
||||
.sources
|
||||
.treehouse
|
||||
.branches_by_named_id
|
||||
.get(&named_id)
|
||||
.copied()
|
||||
.or_else(|| {
|
||||
state
|
||||
.sources
|
||||
.treehouse
|
||||
.branch_redirects
|
||||
.get(&named_id)
|
||||
.copied()
|
||||
});
|
||||
.or_else(|| state.treehouse.branch_redirects.get(&named_id).copied());
|
||||
if let Some(branch_id) = branch_id {
|
||||
let branch = state.sources.treehouse.tree.branch(branch_id);
|
||||
let branch = state.treehouse.tree.branch(branch_id);
|
||||
if let Source::Tree {
|
||||
input, target_path, ..
|
||||
} = state.sources.treehouse.source(branch.file_id)
|
||||
} = state.treehouse.source(branch.file_id)
|
||||
{
|
||||
if let Some(content) = state
|
||||
.target
|
||||
.content(target_path)
|
||||
.await
|
||||
.and_then(|s| String::from_utf8(s).ok())
|
||||
{
|
||||
let branch_markup = input[branch.content.clone()].trim();
|
||||
let mut per_page_metadata =
|
||||
String::from("<meta property=\"og:description\" content=\"");
|
||||
write!(per_page_metadata, "{}", EscapeHtml(branch_markup)).unwrap();
|
||||
per_page_metadata.push_str("\">");
|
||||
match std::fs::read_to_string(target_path) {
|
||||
Ok(content) => {
|
||||
let branch_markdown_content = input[branch.content.clone()].trim();
|
||||
let mut per_page_metadata =
|
||||
String::from("<meta property=\"og:description\" content=\"");
|
||||
escape_html(&mut per_page_metadata, branch_markdown_content).unwrap();
|
||||
per_page_metadata.push_str("\">");
|
||||
|
||||
const PER_PAGE_METADATA_REPLACEMENT_STRING: &str = "<!-- treehouse-ca37057a-cff5-45b3-8415-3b02dbf6c799-per-branch-metadata -->";
|
||||
return Html(content.replacen(
|
||||
PER_PAGE_METADATA_REPLACEMENT_STRING,
|
||||
&per_page_metadata,
|
||||
// Replace one under the assumption that it appears in all pages.
|
||||
1,
|
||||
))
|
||||
.into_response();
|
||||
} else {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("500 Internal Server Error: branch metadata points to entry {target_path} which does not have readable content")
|
||||
)
|
||||
.into_response();
|
||||
const PER_PAGE_METADATA_REPLACEMENT_STRING: &str = "<!-- treehouse-ca37057a-cff5-45b3-8415-3b02dbf6c799-per-branch-metadata -->";
|
||||
return Html(content.replacen(
|
||||
PER_PAGE_METADATA_REPLACEMENT_STRING,
|
||||
&per_page_metadata,
|
||||
// Replace one under the assumption that it appears in all pages.
|
||||
1,
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("error while reading file {target_path:?}: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
system_page(&state.target, system::FOUR_OH_FOUR, StatusCode::NOT_FOUND).await
|
||||
Html(state.system_pages.four_oh_four.clone())
|
||||
} else {
|
||||
system_page(&state.target, system::B_DOCS, StatusCode::OK).await
|
||||
Html(state.system_pages.b_docs.clone())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,28 +1,21 @@
|
|||
use std::time::Duration;
|
||||
use axum::{
|
||||
http::{header::CONTENT_TYPE, Response},
|
||||
Router,
|
||||
};
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use tokio::time::sleep;
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DisableLiveReload;
|
||||
|
||||
pub fn router<S>() -> Router<S> {
|
||||
let router = Router::new().route("/back-up", get(back_up));
|
||||
|
||||
// The endpoint for immediate reload is only enabled on debug builds.
|
||||
// Release builds use the exponential backoff system that detects is the WebSocket is closed.
|
||||
#[cfg(debug_assertions)]
|
||||
let router = router.route("/stall", get(stall));
|
||||
|
||||
router.with_state(())
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
async fn stall() -> String {
|
||||
loop {
|
||||
// Sleep for a day, I guess. Just to uphold the connection forever without really using any
|
||||
// significant resources.
|
||||
sleep(Duration::from_secs(60 * 60 * 24)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn back_up() -> String {
|
||||
"".into()
|
||||
pub fn live_reload(router: Router) -> Router {
|
||||
router.layer(tower_livereload::LiveReloadLayer::new().response_predicate(
|
||||
|response: &Response<_>| {
|
||||
let is_html = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/html"));
|
||||
let is_disabled = response.extensions().get::<DisableLiveReload>().is_some();
|
||||
is_html && !is_disabled
|
||||
},
|
||||
))
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
use std::ops::ControlFlow;
|
||||
use std::{ffi::OsStr, path::Path};
|
||||
|
||||
use anyhow::Context;
|
||||
use treehouse_format::ast::{Branch, Roots};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{
|
||||
parse::parse_tree_with_diagnostics,
|
||||
state::{report_diagnostics, Source, Treehouse},
|
||||
vfs::{self, Dir, VPath},
|
||||
};
|
||||
|
||||
use super::WcArgs;
|
||||
|
@ -28,14 +29,14 @@ fn wc_roots(source: &str, roots: &Roots) -> usize {
|
|||
.sum()
|
||||
}
|
||||
|
||||
pub fn wc_cli(content_dir: &dyn Dir, mut wc_args: WcArgs) -> anyhow::Result<()> {
|
||||
pub fn wc_cli(content_dir: &Path, mut wc_args: WcArgs) -> anyhow::Result<()> {
|
||||
if wc_args.paths.is_empty() {
|
||||
vfs::walk_dir_rec(content_dir, VPath::ROOT, &mut |path| {
|
||||
if path.extension() == Some("tree") {
|
||||
wc_args.paths.push(path.to_owned());
|
||||
for entry in WalkDir::new(content_dir) {
|
||||
let entry = entry?;
|
||||
if entry.file_type().is_file() && entry.path().extension() == Some(OsStr::new("tree")) {
|
||||
wc_args.paths.push(entry.into_path());
|
||||
}
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut treehouse = Treehouse::new();
|
||||
|
@ -43,21 +44,24 @@ pub fn wc_cli(content_dir: &dyn Dir, mut wc_args: WcArgs) -> anyhow::Result<()>
|
|||
let mut total = 0;
|
||||
|
||||
for path in &wc_args.paths {
|
||||
if let Some(content) = content_dir
|
||||
.content(path)
|
||||
.and_then(|b| String::from_utf8(b).ok())
|
||||
{
|
||||
let file_id = treehouse.add_file(path.to_string(), Source::Other(content));
|
||||
match parse_tree_with_diagnostics(&mut treehouse, file_id) {
|
||||
Ok(parsed) => {
|
||||
let source = treehouse.source(file_id);
|
||||
let word_count = wc_roots(source.input(), &parsed);
|
||||
println!("{word_count:>8} {}", treehouse.filename(file_id));
|
||||
total += word_count;
|
||||
}
|
||||
Err(diagnostics) => {
|
||||
report_diagnostics(&treehouse.files, &diagnostics)?;
|
||||
}
|
||||
let file = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read file to word count: {path:?}"))?;
|
||||
let path_without_ext = path.with_extension("");
|
||||
let utf8_filename = path_without_ext
|
||||
.strip_prefix(content_dir)
|
||||
.expect("paths should be rooted within the content directory")
|
||||
.to_string_lossy();
|
||||
|
||||
let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file));
|
||||
match parse_tree_with_diagnostics(&mut treehouse, file_id) {
|
||||
Ok(parsed) => {
|
||||
let source = treehouse.source(file_id);
|
||||
let word_count = wc_roots(source.input(), &parsed);
|
||||
println!("{word_count:>8} {}", treehouse.filename(file_id));
|
||||
total += word_count;
|
||||
}
|
||||
Err(diagnostics) => {
|
||||
report_diagnostics(&treehouse.files, &diagnostics)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
use std::{collections::HashMap, ops::ControlFlow};
|
||||
use std::{collections::HashMap, ffi::OsStr, fs::File, io::BufReader, path::Path};
|
||||
|
||||
use anyhow::{anyhow, Context};
|
||||
use log::{debug, error};
|
||||
use anyhow::Context;
|
||||
use image::ImageError;
|
||||
use log::{debug, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::{
|
||||
html::highlight::{
|
||||
|
@ -10,7 +12,7 @@ use crate::{
|
|||
Syntax,
|
||||
},
|
||||
import_map::ImportRoot,
|
||||
vfs::{self, Dir, ImageSize, VPath, VPathBuf},
|
||||
static_urls::StaticUrls,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
|
@ -20,6 +22,10 @@ pub struct Config {
|
|||
/// preferred way of setting this in production, so as not to clobber treehouse.toml.)
|
||||
pub site: String,
|
||||
|
||||
/// Which markup to use when generating trees.
|
||||
/// TODO djot: Remove this once we transition to Djot fully.
|
||||
pub markup: Markup,
|
||||
|
||||
/// This is used to generate a link in the footer that links to the page's source commit.
|
||||
/// The final URL is `{commit_base_url}/{commit}/content/{tree_path}.tree`.
|
||||
pub commit_base_url: String,
|
||||
|
@ -53,17 +59,17 @@ pub struct Config {
|
|||
/// How the treehouse should be built.
|
||||
pub build: Build,
|
||||
|
||||
/// Overrides for emoji names. Useful for setting up aliases.
|
||||
/// Overrides for emoji filenames. Useful for setting up aliases.
|
||||
///
|
||||
/// Paths are anchored within `static/emoji` and must not contain parent directories.
|
||||
/// On top of this, emojis are autodiscovered by walking the `static/emoji` directory.
|
||||
#[serde(default)]
|
||||
pub emoji: HashMap<String, VPathBuf>,
|
||||
pub emoji: HashMap<String, String>,
|
||||
|
||||
/// Overrides for pic filenames. Useful for setting up aliases.
|
||||
///
|
||||
/// On top of this, pics are autodiscovered by walking the `static/pic` directory.
|
||||
/// Only the part before the first dash is treated as the pic's id.
|
||||
pub pics: HashMap<String, VPathBuf>,
|
||||
pub pics: HashMap<String, String>,
|
||||
|
||||
/// Syntax definitions.
|
||||
///
|
||||
|
@ -99,39 +105,72 @@ pub enum Markup {
|
|||
}
|
||||
|
||||
impl Config {
|
||||
pub fn autopopulate_emoji(&mut self, dir: &dyn Dir) -> anyhow::Result<()> {
|
||||
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
|
||||
if path.extension().is_some_and(is_image_file) {
|
||||
if let Some(emoji_name) = path.file_stem() {
|
||||
if !self.emoji.contains_key(emoji_name) {
|
||||
self.emoji.insert(emoji_name.to_owned(), path.to_owned());
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let string = std::fs::read_to_string(path).context("cannot read config file")?;
|
||||
toml_edit::de::from_str(&string).context("error in config file")
|
||||
}
|
||||
|
||||
fn is_emoji_file(path: &Path) -> bool {
|
||||
path.extension() == Some(OsStr::new("png")) || path.extension() == Some(OsStr::new("svg"))
|
||||
}
|
||||
|
||||
pub fn autopopulate_emoji(&mut self, dir: &Path) -> anyhow::Result<()> {
|
||||
for file in WalkDir::new(dir) {
|
||||
let entry = file?;
|
||||
if entry.file_type().is_file() && Self::is_emoji_file(entry.path()) {
|
||||
if let Some(emoji_name) = entry.path().file_stem() {
|
||||
let emoji_name = emoji_name.to_string_lossy();
|
||||
if !self.emoji.contains_key(emoji_name.as_ref()) {
|
||||
self.emoji.insert(
|
||||
emoji_name.into_owned(),
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(dir)
|
||||
.unwrap_or(entry.path())
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn autopopulate_pics(&mut self, dir: &dyn Dir) -> anyhow::Result<()> {
|
||||
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
|
||||
if path.extension().is_some_and(is_image_file) {
|
||||
if let Some(pic_name) = path.file_stem() {
|
||||
fn is_pic_file(path: &Path) -> bool {
|
||||
path.extension() == Some(OsStr::new("png"))
|
||||
|| path.extension() == Some(OsStr::new("svg"))
|
||||
|| path.extension() == Some(OsStr::new("jpg"))
|
||||
|| path.extension() == Some(OsStr::new("jpeg"))
|
||||
|| path.extension() == Some(OsStr::new("webp"))
|
||||
}
|
||||
|
||||
pub fn autopopulate_pics(&mut self, dir: &Path) -> anyhow::Result<()> {
|
||||
for file in WalkDir::new(dir) {
|
||||
let entry = file?;
|
||||
if entry.file_type().is_file() && Self::is_pic_file(entry.path()) {
|
||||
if let Some(pic_name) = entry.path().file_stem() {
|
||||
let pic_name = pic_name.to_string_lossy();
|
||||
|
||||
let pic_id = pic_name
|
||||
.split_once('-')
|
||||
.map(|(before_dash, _after_dash)| before_dash)
|
||||
.unwrap_or(pic_name);
|
||||
.unwrap_or(&pic_name);
|
||||
|
||||
if !self.pics.contains_key(pic_id) {
|
||||
self.pics.insert(pic_id.to_owned(), path.to_owned());
|
||||
self.pics.insert(
|
||||
pic_id.to_owned(),
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(dir)
|
||||
.unwrap_or(entry.path())
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -139,56 +178,79 @@ impl Config {
|
|||
format!("{}/{}", self.site, page)
|
||||
}
|
||||
|
||||
pub fn pic_url(&self, pics_dir: &dyn Dir, id: &str) -> String {
|
||||
vfs::url(
|
||||
&self.site,
|
||||
pics_dir,
|
||||
self.pics
|
||||
.get(id)
|
||||
.map(|x| &**x)
|
||||
.unwrap_or(VPath::new("404.png")),
|
||||
pub fn pic_url(&self, id: &str) -> String {
|
||||
format!(
|
||||
"{}/static/pic/{}",
|
||||
self.site,
|
||||
self.pics.get(id).map(|x| &**x).unwrap_or("404.png")
|
||||
)
|
||||
.expect("pics_dir is not anchored anywhere")
|
||||
}
|
||||
|
||||
pub fn pic_size(&self, pics_dir: &dyn Dir, id: &str) -> Option<ImageSize> {
|
||||
self.pics.get(id).and_then(|path| pics_dir.image_size(path))
|
||||
}
|
||||
|
||||
/// Loads all syntax definition files.
|
||||
pub fn load_syntaxes(&mut self, dir: &dyn Dir) -> anyhow::Result<()> {
|
||||
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
|
||||
if path.extension() == Some("json") {
|
||||
let name = path
|
||||
pub fn load_syntaxes(&mut self, dir: &Path) -> anyhow::Result<()> {
|
||||
for entry in WalkDir::new(dir) {
|
||||
let entry = entry?;
|
||||
if entry.path().extension() == Some(OsStr::new("json")) {
|
||||
let name = entry
|
||||
.path()
|
||||
.file_stem()
|
||||
.expect("syntax file name should have a stem due to the .json extension");
|
||||
.expect("syntax file name should have a stem")
|
||||
.to_string_lossy();
|
||||
debug!("loading syntax {name:?}");
|
||||
|
||||
let result: Result<Syntax, _> = dir
|
||||
.content(path)
|
||||
.ok_or_else(|| anyhow!("syntax .json is not a file"))
|
||||
.and_then(|b| {
|
||||
String::from_utf8(b).context("syntax .json contains invalid UTF-8")
|
||||
})
|
||||
.and_then(|s| {
|
||||
serde_json::from_str(&s).context("could not deserialize syntax file")
|
||||
});
|
||||
match result {
|
||||
Ok(syntax) => {
|
||||
let compiled = compile_syntax(&syntax);
|
||||
self.syntaxes.insert(name.to_owned(), compiled);
|
||||
}
|
||||
Err(err) => error!("error while loading syntax file `{path}`: {err}"),
|
||||
}
|
||||
let syntax: Syntax = serde_json::from_reader(BufReader::new(
|
||||
File::open(entry.path()).context("could not open syntax file")?,
|
||||
))
|
||||
.context("could not deserialize syntax file")?;
|
||||
let compiled = compile_syntax(&syntax);
|
||||
self.syntaxes.insert(name.into_owned(), compiled);
|
||||
}
|
||||
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_image_file(extension: &str) -> bool {
|
||||
matches!(extension, "png" | "svg" | "jpg" | "jpeg" | "webp")
|
||||
/// Data derived from the config.
|
||||
pub struct ConfigDerivedData {
|
||||
pub image_sizes: HashMap<String, Option<ImageSize>>,
|
||||
pub static_urls: StaticUrls,
|
||||
}
|
||||
|
||||
/// Image size. This is useful for emitting <img> elements with a specific size to eliminate
|
||||
/// layout shifting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ImageSize {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl ConfigDerivedData {
|
||||
fn read_image_size(filename: &str) -> Option<ImageSize> {
|
||||
let (width, height) = image::io::Reader::new(BufReader::new(File::open(filename).ok()?))
|
||||
.with_guessed_format()
|
||||
.map_err(ImageError::from)
|
||||
.and_then(|i| i.into_dimensions())
|
||||
// NOTE: Not being able to determine the image size is not the end of the world,
|
||||
// so just warn the user if we couldn't do it.
|
||||
// For example, currently SVG is not supported at all, which causes this to fail.
|
||||
.inspect_err(|e| warn!("cannot read image size of {filename}: {e}"))
|
||||
.ok()?;
|
||||
Some(ImageSize { width, height })
|
||||
}
|
||||
|
||||
pub fn image_size(&mut self, filename: &str) -> Option<ImageSize> {
|
||||
if !self.image_sizes.contains_key(filename) {
|
||||
self.image_sizes
|
||||
.insert(filename.to_owned(), Self::read_image_size(filename));
|
||||
}
|
||||
self.image_sizes.get(filename).copied().flatten()
|
||||
}
|
||||
|
||||
pub fn pic_size(&mut self, config: &Config, pic_id: &str) -> Option<ImageSize> {
|
||||
config
|
||||
.pics
|
||||
.get(pic_id)
|
||||
.and_then(|pic_filename| self.image_size(&format!("static/pic/{pic_filename}")))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
use crate::vfs::DynDir;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dirs {
|
||||
pub root: DynDir,
|
||||
|
||||
pub content: DynDir,
|
||||
pub static_: DynDir,
|
||||
pub template: DynDir,
|
||||
|
||||
// `static` directories
|
||||
pub pic: DynDir,
|
||||
pub emoji: DynDir,
|
||||
pub syntax: DynDir,
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,37 +0,0 @@
|
|||
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::vfs::{self, DynDir, VPath};
|
||||
|
||||
pub struct DirHelper {
|
||||
site: String,
|
||||
dir: DynDir,
|
||||
}
|
||||
|
||||
impl DirHelper {
|
||||
pub fn new(site: &str, dir: DynDir) -> Self {
|
||||
Self {
|
||||
site: site.to_owned(),
|
||||
dir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HelperDef for DirHelper {
|
||||
fn call_inner<'reg: 'rc, 'rc>(
|
||||
&self,
|
||||
h: &Helper<'reg, 'rc>,
|
||||
_: &'reg Handlebars<'reg>,
|
||||
_: &'rc Context,
|
||||
_: &mut RenderContext<'reg, 'rc>,
|
||||
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
|
||||
if let Some(path) = h.param(0).and_then(|v| v.value().as_str()) {
|
||||
let vpath = VPath::try_new(path).map_err(|e| RenderError::new(e.to_string()))?;
|
||||
let url = vfs::url(&self.site, &self.dir, vpath)
|
||||
.ok_or_else(|| RenderError::new("path is not anchored anywhere"))?;
|
||||
Ok(ScopedJson::Derived(Value::String(url)))
|
||||
} else {
|
||||
Err(RenderError::new("missing path string"))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::vfs::{DynDir, VPath};
|
||||
|
||||
pub struct IncludeStaticHelper {
|
||||
dir: DynDir,
|
||||
}
|
||||
|
||||
impl IncludeStaticHelper {
|
||||
pub fn new(dir: DynDir) -> Self {
|
||||
Self { dir }
|
||||
}
|
||||
}
|
||||
|
||||
impl HelperDef for IncludeStaticHelper {
|
||||
fn call_inner<'reg: 'rc, 'rc>(
|
||||
&self,
|
||||
h: &Helper<'reg, 'rc>,
|
||||
_: &'reg Handlebars<'reg>,
|
||||
_: &'rc Context,
|
||||
_: &mut RenderContext<'reg, 'rc>,
|
||||
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
|
||||
if let Some(path) = h.param(0).and_then(|v| v.value().as_str()) {
|
||||
let vpath = VPath::try_new(path).map_err(|e| RenderError::new(e.to_string()))?;
|
||||
let url = String::from_utf8(
|
||||
self.dir
|
||||
.content(vpath)
|
||||
.ok_or_else(|| RenderError::new("file does not exist"))?,
|
||||
)
|
||||
.map_err(|_| RenderError::new("included file does not contain UTF-8 text"))?;
|
||||
Ok(ScopedJson::Derived(Value::String(url)))
|
||||
} else {
|
||||
Err(RenderError::new("missing path string"))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,12 +3,13 @@ use std::fmt::{self, Display, Write};
|
|||
pub mod breadcrumbs;
|
||||
mod djot;
|
||||
pub mod highlight;
|
||||
mod markdown;
|
||||
pub mod navmap;
|
||||
pub mod tree;
|
||||
|
||||
pub struct EscapeAttribute<'a>(pub &'a str);
|
||||
pub struct EscapeAttribute<'a>(&'a str);
|
||||
|
||||
impl Display for EscapeAttribute<'_> {
|
||||
impl<'a> Display for EscapeAttribute<'a> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for c in self.0.chars() {
|
||||
if c == '"' {
|
||||
|
@ -21,9 +22,9 @@ impl Display for EscapeAttribute<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct EscapeHtml<'a>(pub &'a str);
|
||||
pub struct EscapeHtml<'a>(&'a str);
|
||||
|
||||
impl Display for EscapeHtml<'_> {
|
||||
impl<'a> Display for EscapeHtml<'a> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for c in self.0.chars() {
|
||||
match c {
|
||||
|
|
|
@ -17,25 +17,22 @@ use jotdown::OrderedListNumbering::*;
|
|||
use jotdown::SpanLinkType;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::dirs::Dirs;
|
||||
use crate::config::ConfigDerivedData;
|
||||
use crate::state::FileId;
|
||||
use crate::state::Treehouse;
|
||||
use crate::vfs;
|
||||
|
||||
use super::highlight::highlight;
|
||||
|
||||
/// [`Render`] implementor that writes HTML output.
|
||||
pub struct Renderer<'a> {
|
||||
pub config: &'a Config,
|
||||
|
||||
pub dirs: &'a Dirs,
|
||||
|
||||
pub treehouse: &'a Treehouse,
|
||||
pub config_derived_data: &'a mut ConfigDerivedData,
|
||||
pub treehouse: &'a mut Treehouse,
|
||||
pub file_id: FileId,
|
||||
pub page_id: String,
|
||||
}
|
||||
|
||||
impl Renderer<'_> {
|
||||
impl<'a> Renderer<'a> {
|
||||
#[must_use]
|
||||
pub fn render(
|
||||
self,
|
||||
|
@ -372,18 +369,25 @@ impl<'a> Writer<'a> {
|
|||
r#"<img class="placeholder-image" loading="lazy" src=""#,
|
||||
);
|
||||
|
||||
let pic_url = self
|
||||
.renderer
|
||||
.config
|
||||
.pic_url(&*self.renderer.dirs.pic, placeholder_pic_id);
|
||||
let filename = self.renderer.config.pics.get(placeholder_pic_id);
|
||||
let pic_url = filename
|
||||
.and_then(|filename| {
|
||||
self.renderer
|
||||
.config_derived_data
|
||||
.static_urls
|
||||
.get(&format!("pic/{filename}"))
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
write_attr(&pic_url, out);
|
||||
out.push('"');
|
||||
|
||||
if let Some(image_size) = self
|
||||
.renderer
|
||||
.config
|
||||
.pic_size(&*self.renderer.dirs.pic, placeholder_pic_id)
|
||||
{
|
||||
let image_size = filename.and_then(|filename| {
|
||||
self.renderer
|
||||
.config_derived_data
|
||||
.image_size(&format!("static/pic/{filename}"))
|
||||
});
|
||||
if let Some(image_size) = image_size {
|
||||
write!(
|
||||
out,
|
||||
r#" width="{}" height="{}""#,
|
||||
|
@ -478,7 +482,6 @@ impl<'a> Writer<'a> {
|
|||
if !src.is_empty() {
|
||||
out.push_str(r#"" src=""#);
|
||||
if let SpanLinkType::Unresolved = link_type {
|
||||
// TODO: Image size.
|
||||
if let Some(resolved) = self.resolve_link(src) {
|
||||
write_attr(&resolved, out);
|
||||
} else {
|
||||
|
@ -520,7 +523,8 @@ impl<'a> Writer<'a> {
|
|||
self.renderer.config.syntaxes.get(code_block.language)
|
||||
});
|
||||
if let Some(syntax) = syntax {
|
||||
highlight(out, syntax, s);
|
||||
// TODO djot: make highlight infallible
|
||||
highlight(out, syntax, s).map_err(|_| std::fmt::Error)?;
|
||||
} else {
|
||||
write_text(s, out);
|
||||
}
|
||||
|
@ -543,7 +547,7 @@ impl<'a> Writer<'a> {
|
|||
});
|
||||
}
|
||||
Event::Symbol(sym) => {
|
||||
if let Some(vpath) = self.renderer.config.emoji.get(sym.as_ref()) {
|
||||
if let Some(filename) = self.renderer.config.emoji.get(sym.as_ref()) {
|
||||
let branch_id = self
|
||||
.renderer
|
||||
.treehouse
|
||||
|
@ -561,12 +565,12 @@ impl<'a> Writer<'a> {
|
|||
out.push_str(r#"">"#)
|
||||
}
|
||||
|
||||
let url = vfs::url(
|
||||
&self.renderer.config.site,
|
||||
&*self.renderer.dirs.emoji,
|
||||
vpath,
|
||||
)
|
||||
.expect("emoji directory is not anchored anywhere");
|
||||
let url = self
|
||||
.renderer
|
||||
.config_derived_data
|
||||
.static_urls
|
||||
.get(&format!("emoji/{filename}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
// TODO: this could do with better alt text
|
||||
write!(
|
||||
|
@ -576,7 +580,11 @@ impl<'a> Writer<'a> {
|
|||
write_attr(&url, out);
|
||||
out.push('"');
|
||||
|
||||
if let Some(image_size) = self.renderer.dirs.emoji.image_size(vpath) {
|
||||
if let Some(image_size) = self
|
||||
.renderer
|
||||
.config_derived_data
|
||||
.image_size(&format!("static/emoji/{filename}"))
|
||||
{
|
||||
write!(
|
||||
out,
|
||||
r#" width="{}" height="{}""#,
|
||||
|
@ -627,7 +635,10 @@ impl<'a> Writer<'a> {
|
|||
|
||||
fn resolve_link(&self, link: &str) -> Option<String> {
|
||||
let Renderer {
|
||||
config, treehouse, ..
|
||||
config,
|
||||
config_derived_data,
|
||||
treehouse,
|
||||
..
|
||||
} = &self.renderer;
|
||||
link.split_once(':').and_then(|(kind, linked)| match kind {
|
||||
"def" => config.defs.get(linked).cloned(),
|
||||
|
@ -642,7 +653,12 @@ impl<'a> Writer<'a> {
|
|||
)
|
||||
}),
|
||||
"page" => Some(config.page_url(linked)),
|
||||
"pic" => Some(config.pic_url(&*self.renderer.dirs.pic, linked)),
|
||||
"pic" => config.pics.get(linked).and_then(|filename| {
|
||||
config_derived_data
|
||||
.static_urls
|
||||
.get(&format!("pic/{filename}"))
|
||||
.ok()
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,14 +11,13 @@
|
|||
pub mod compiled;
|
||||
pub mod tokenize;
|
||||
|
||||
use std::{collections::HashMap, fmt::Write};
|
||||
use std::{collections::HashMap, io};
|
||||
|
||||
use pulldown_cmark::escape::{escape_html, StrWrite};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use self::compiled::CompiledSyntax;
|
||||
|
||||
use super::EscapeHtml;
|
||||
|
||||
/// Syntax definition.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Syntax {
|
||||
|
@ -82,13 +81,14 @@ pub struct Keyword {
|
|||
pub only_replaces: Option<String>,
|
||||
}
|
||||
|
||||
pub fn highlight(out: &mut String, syntax: &CompiledSyntax, code: &str) {
|
||||
pub fn highlight(mut w: impl StrWrite, syntax: &CompiledSyntax, code: &str) -> io::Result<()> {
|
||||
let tokens = syntax.tokenize(code);
|
||||
for token in tokens {
|
||||
out.push_str("<span class=\"");
|
||||
_ = write!(out, "{}", EscapeHtml(&syntax.token_names[token.id]));
|
||||
out.push_str("\">");
|
||||
_ = write!(out, "{}", EscapeHtml(&code[token.range]));
|
||||
out.push_str("</span>");
|
||||
w.write_str("<span class=\"")?;
|
||||
escape_html(&mut w, &syntax.token_names[token.id])?;
|
||||
w.write_str("\">")?;
|
||||
escape_html(&mut w, &code[token.range])?;
|
||||
w.write_str("</span>")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
716
crates/treehouse/src/html/markdown.rs
Normal file
716
crates/treehouse/src/html/markdown.rs
Normal file
|
@ -0,0 +1,716 @@
|
|||
// NOTE: This code is pasted pretty much verbatim from pulldown-cmark but tweaked to have my own
|
||||
// cool additions.
|
||||
|
||||
// Copyright 2015 Google Inc. All rights reserved.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
//! HTML renderer that takes an iterator of events as input.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
|
||||
use pulldown_cmark::escape::{escape_href, escape_html, StrWrite};
|
||||
use pulldown_cmark::{Alignment, CodeBlockKind, Event, LinkType, Tag};
|
||||
use pulldown_cmark::{CowStr, Event::*};
|
||||
|
||||
use crate::config::{Config, ConfigDerivedData, ImageSize};
|
||||
use crate::html::highlight::highlight;
|
||||
use crate::state::Treehouse;
|
||||
|
||||
enum TableState {
|
||||
Head,
|
||||
Body,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum CodeBlockState<'a> {
|
||||
NotInCodeBlock,
|
||||
InCodeBlock(Option<CowStr<'a>>),
|
||||
}
|
||||
|
||||
struct HtmlWriter<'a, I, W> {
|
||||
treehouse: &'a Treehouse,
|
||||
config: &'a Config,
|
||||
config_derived_data: &'a mut ConfigDerivedData,
|
||||
page_id: &'a str,
|
||||
|
||||
/// Iterator supplying events.
|
||||
iter: I,
|
||||
|
||||
/// Writer to write to.
|
||||
writer: W,
|
||||
|
||||
/// Whether or not the last write wrote a newline.
|
||||
end_newline: bool,
|
||||
|
||||
table_state: TableState,
|
||||
table_alignments: Vec<Alignment>,
|
||||
table_cell_index: usize,
|
||||
numbers: HashMap<CowStr<'a>, usize>,
|
||||
|
||||
code_block_state: CodeBlockState<'a>,
|
||||
}
|
||||
|
||||
impl<'a, I, W> HtmlWriter<'a, I, W>
|
||||
where
|
||||
I: Iterator<Item = Event<'a>>,
|
||||
W: StrWrite,
|
||||
{
|
||||
fn new(
|
||||
treehouse: &'a Treehouse,
|
||||
config: &'a Config,
|
||||
config_derived_data: &'a mut ConfigDerivedData,
|
||||
page_id: &'a str,
|
||||
iter: I,
|
||||
writer: W,
|
||||
) -> Self {
|
||||
Self {
|
||||
treehouse,
|
||||
config,
|
||||
config_derived_data,
|
||||
page_id,
|
||||
|
||||
iter,
|
||||
writer,
|
||||
end_newline: true,
|
||||
table_state: TableState::Head,
|
||||
table_alignments: vec![],
|
||||
table_cell_index: 0,
|
||||
numbers: HashMap::new(),
|
||||
code_block_state: CodeBlockState::NotInCodeBlock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a new line.
|
||||
fn write_newline(&mut self) -> io::Result<()> {
|
||||
self.end_newline = true;
|
||||
self.writer.write_str("\n")
|
||||
}
|
||||
|
||||
/// Writes a buffer, and tracks whether or not a newline was written.
|
||||
#[inline]
|
||||
fn write(&mut self, s: &str) -> io::Result<()> {
|
||||
self.writer.write_str(s)?;
|
||||
|
||||
if !s.is_empty() {
|
||||
self.end_newline = s.ends_with('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(mut self) -> io::Result<()> {
|
||||
while let Some(event) = self.iter.next() {
|
||||
match event {
|
||||
Start(tag) => {
|
||||
self.start_tag(tag)?;
|
||||
}
|
||||
End(tag) => {
|
||||
self.end_tag(tag)?;
|
||||
}
|
||||
Text(text) => {
|
||||
self.run_text(&text)?;
|
||||
self.end_newline = text.ends_with('\n');
|
||||
}
|
||||
Code(text) => {
|
||||
self.write("<code>")?;
|
||||
escape_html(&mut self.writer, &text)?;
|
||||
self.write("</code>")?;
|
||||
}
|
||||
Html(html) => {
|
||||
self.write(&html)?;
|
||||
}
|
||||
SoftBreak => {
|
||||
self.write_newline()?;
|
||||
}
|
||||
HardBreak => {
|
||||
self.write("<br />\n")?;
|
||||
}
|
||||
Rule => {
|
||||
if self.end_newline {
|
||||
self.write("<hr />\n")?;
|
||||
} else {
|
||||
self.write("\n<hr />\n")?;
|
||||
}
|
||||
}
|
||||
FootnoteReference(name) => {
|
||||
let len = self.numbers.len() + 1;
|
||||
self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
|
||||
escape_html(&mut self.writer, &name)?;
|
||||
self.write("\">")?;
|
||||
let number = *self.numbers.entry(name).or_insert(len);
|
||||
write!(&mut self.writer, "{}", number)?;
|
||||
self.write("</a></sup>")?;
|
||||
}
|
||||
TaskListMarker(true) => {
|
||||
self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?;
|
||||
}
|
||||
TaskListMarker(false) => {
|
||||
self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the start of an HTML tag.
|
||||
fn start_tag(&mut self, tag: Tag<'a>) -> io::Result<()> {
|
||||
match tag {
|
||||
Tag::Paragraph => {
|
||||
if self.end_newline {
|
||||
self.write("<p>")
|
||||
} else {
|
||||
self.write("\n<p>")
|
||||
}
|
||||
}
|
||||
Tag::Heading(level, id, classes) => {
|
||||
if self.end_newline {
|
||||
self.end_newline = false;
|
||||
self.write("<")?;
|
||||
} else {
|
||||
self.write("\n<")?;
|
||||
}
|
||||
write!(&mut self.writer, "{}", level)?;
|
||||
if let Some(id) = id {
|
||||
self.write(" id=\"")?;
|
||||
escape_html(&mut self.writer, id)?;
|
||||
self.write("\"")?;
|
||||
}
|
||||
let mut classes = classes.iter();
|
||||
if let Some(class) = classes.next() {
|
||||
self.write(" class=\"")?;
|
||||
escape_html(&mut self.writer, class)?;
|
||||
for class in classes {
|
||||
self.write(" ")?;
|
||||
escape_html(&mut self.writer, class)?;
|
||||
}
|
||||
self.write("\"")?;
|
||||
}
|
||||
self.write(">")
|
||||
}
|
||||
Tag::Table(alignments) => {
|
||||
self.table_alignments = alignments;
|
||||
self.write("<table>")
|
||||
}
|
||||
Tag::TableHead => {
|
||||
self.table_state = TableState::Head;
|
||||
self.table_cell_index = 0;
|
||||
self.write("<thead><tr>")
|
||||
}
|
||||
Tag::TableRow => {
|
||||
self.table_cell_index = 0;
|
||||
self.write("<tr>")
|
||||
}
|
||||
Tag::TableCell => {
|
||||
match self.table_state {
|
||||
TableState::Head => {
|
||||
self.write("<th")?;
|
||||
}
|
||||
TableState::Body => {
|
||||
self.write("<td")?;
|
||||
}
|
||||
}
|
||||
match self.table_alignments.get(self.table_cell_index) {
|
||||
Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
|
||||
Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
|
||||
Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
|
||||
_ => self.write(">"),
|
||||
}
|
||||
}
|
||||
Tag::BlockQuote => {
|
||||
if self.end_newline {
|
||||
self.write("<blockquote>\n")
|
||||
} else {
|
||||
self.write("\n<blockquote>\n")
|
||||
}
|
||||
}
|
||||
Tag::CodeBlock(info) => {
|
||||
self.code_block_state = CodeBlockState::InCodeBlock(None);
|
||||
if !self.end_newline {
|
||||
self.write_newline()?;
|
||||
}
|
||||
match info {
|
||||
CodeBlockKind::Fenced(language) => {
|
||||
self.code_block_state = CodeBlockState::InCodeBlock(Some(language.clone()));
|
||||
match CodeBlockMode::parse(&language) {
|
||||
CodeBlockMode::PlainText => self.write("<pre><code>"),
|
||||
CodeBlockMode::SyntaxHighlightOnly { language } => {
|
||||
self.write("<pre><code class=\"language-")?;
|
||||
escape_html(&mut self.writer, language)?;
|
||||
if self.config.syntaxes.contains_key(language) {
|
||||
self.write(" th-syntax-highlighting")?;
|
||||
}
|
||||
self.write("\">")
|
||||
}
|
||||
CodeBlockMode::LiterateProgram {
|
||||
language,
|
||||
kind,
|
||||
program_name,
|
||||
} => {
|
||||
self.write(match &kind {
|
||||
LiterateCodeKind::Input => {
|
||||
"<th-literate-program data-mode=\"input\" "
|
||||
}
|
||||
LiterateCodeKind::Output { .. } => {
|
||||
"<th-literate-program data-mode=\"output\" "
|
||||
}
|
||||
})?;
|
||||
self.write("data-program=\"")?;
|
||||
escape_href(&mut self.writer, self.page_id)?;
|
||||
self.write(":")?;
|
||||
escape_html(&mut self.writer, program_name)?;
|
||||
self.write("\" data-language=\"")?;
|
||||
escape_html(&mut self.writer, language)?;
|
||||
self.write("\" role=\"code\">")?;
|
||||
|
||||
if let LiterateCodeKind::Output { placeholder_pic_id } = kind {
|
||||
if !placeholder_pic_id.is_empty() {
|
||||
self.write("<img class=\"placeholder-image\" loading=\"lazy\" src=\"")?;
|
||||
escape_html(
|
||||
&mut self.writer,
|
||||
&self.config.pic_url(placeholder_pic_id),
|
||||
)?;
|
||||
self.write("\"")?;
|
||||
if let Some(ImageSize { width, height }) = self
|
||||
.config_derived_data
|
||||
.pic_size(self.config, placeholder_pic_id)
|
||||
{
|
||||
self.write(&format!(
|
||||
" width=\"{width}\" height=\"{height}\""
|
||||
))?;
|
||||
}
|
||||
self.write(">")?;
|
||||
}
|
||||
}
|
||||
|
||||
self.write("<pre class=\"placeholder-console\">")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
CodeBlockKind::Indented => self.write("<pre><code>"),
|
||||
}
|
||||
}
|
||||
Tag::List(Some(1)) => {
|
||||
if self.end_newline {
|
||||
self.write("<ol>\n")
|
||||
} else {
|
||||
self.write("\n<ol>\n")
|
||||
}
|
||||
}
|
||||
Tag::List(Some(start)) => {
|
||||
if self.end_newline {
|
||||
self.write("<ol start=\"")?;
|
||||
} else {
|
||||
self.write("\n<ol start=\"")?;
|
||||
}
|
||||
write!(&mut self.writer, "{}", start)?;
|
||||
self.write("\">\n")
|
||||
}
|
||||
Tag::List(None) => {
|
||||
if self.end_newline {
|
||||
self.write("<ul>\n")
|
||||
} else {
|
||||
self.write("\n<ul>\n")
|
||||
}
|
||||
}
|
||||
Tag::Item => {
|
||||
if self.end_newline {
|
||||
self.write("<li>")
|
||||
} else {
|
||||
self.write("\n<li>")
|
||||
}
|
||||
}
|
||||
Tag::Emphasis => self.write("<em>"),
|
||||
Tag::Strong => self.write("<strong>"),
|
||||
Tag::Strikethrough => self.write("<del>"),
|
||||
Tag::Link(LinkType::Email, dest, title) => {
|
||||
self.write("<a href=\"mailto:")?;
|
||||
escape_href(&mut self.writer, &dest)?;
|
||||
if !title.is_empty() {
|
||||
self.write("\" title=\"")?;
|
||||
escape_html(&mut self.writer, &title)?;
|
||||
}
|
||||
self.write("\">")
|
||||
}
|
||||
Tag::Link(_link_type, dest, title) => {
|
||||
self.write("<a href=\"")?;
|
||||
escape_href(&mut self.writer, &dest)?;
|
||||
if !title.is_empty() {
|
||||
self.write("\" title=\"")?;
|
||||
escape_html(&mut self.writer, &title)?;
|
||||
}
|
||||
self.write("\">")
|
||||
}
|
||||
Tag::Image(_link_type, dest, title) => {
|
||||
self.write("<img class=\"pic\" src=\"")?;
|
||||
escape_href(&mut self.writer, &dest)?;
|
||||
self.write("\" alt=\"")?;
|
||||
self.raw_text()?;
|
||||
if !title.is_empty() {
|
||||
self.write("\" title=\"")?;
|
||||
escape_html(&mut self.writer, &title)?;
|
||||
}
|
||||
self.write("\" />")
|
||||
}
|
||||
Tag::FootnoteDefinition(name) => {
|
||||
if self.end_newline {
|
||||
self.write("<div class=\"footnote-definition\" id=\"")?;
|
||||
} else {
|
||||
self.write("\n<div class=\"footnote-definition\" id=\"")?;
|
||||
}
|
||||
escape_html(&mut self.writer, &name)?;
|
||||
self.write("\"><sup class=\"footnote-definition-label\">")?;
|
||||
let len = self.numbers.len() + 1;
|
||||
let number = *self.numbers.entry(name).or_insert(len);
|
||||
write!(&mut self.writer, "{}", number)?;
|
||||
self.write("</sup>")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn end_tag(&mut self, tag: Tag) -> io::Result<()> {
|
||||
match tag {
|
||||
Tag::Paragraph => {
|
||||
self.write("</p>\n")?;
|
||||
}
|
||||
Tag::Heading(level, _id, _classes) => {
|
||||
self.write("</")?;
|
||||
write!(&mut self.writer, "{}", level)?;
|
||||
self.write(">\n")?;
|
||||
}
|
||||
Tag::Table(_) => {
|
||||
self.write("</tbody></table>\n")?;
|
||||
}
|
||||
Tag::TableHead => {
|
||||
self.write("</tr></thead><tbody>\n")?;
|
||||
self.table_state = TableState::Body;
|
||||
}
|
||||
Tag::TableRow => {
|
||||
self.write("</tr>\n")?;
|
||||
}
|
||||
Tag::TableCell => {
|
||||
match self.table_state {
|
||||
TableState::Head => {
|
||||
self.write("</th>")?;
|
||||
}
|
||||
TableState::Body => {
|
||||
self.write("</td>")?;
|
||||
}
|
||||
}
|
||||
self.table_cell_index += 1;
|
||||
}
|
||||
Tag::BlockQuote => {
|
||||
self.write("</blockquote>\n")?;
|
||||
}
|
||||
Tag::CodeBlock(kind) => {
|
||||
self.write(match kind {
|
||||
CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) {
|
||||
CodeBlockMode::LiterateProgram { .. } => "</pre></th-literate-program>",
|
||||
_ => "</code></pre>",
|
||||
},
|
||||
_ => "</code></pre>\n",
|
||||
})?;
|
||||
self.code_block_state = CodeBlockState::NotInCodeBlock;
|
||||
}
|
||||
Tag::List(Some(_)) => {
|
||||
self.write("</ol>\n")?;
|
||||
}
|
||||
Tag::List(None) => {
|
||||
self.write("</ul>\n")?;
|
||||
}
|
||||
Tag::Item => {
|
||||
self.write("</li>\n")?;
|
||||
}
|
||||
Tag::Emphasis => {
|
||||
self.write("</em>")?;
|
||||
}
|
||||
Tag::Strong => {
|
||||
self.write("</strong>")?;
|
||||
}
|
||||
Tag::Strikethrough => {
|
||||
self.write("</del>")?;
|
||||
}
|
||||
Tag::Link(_, _, _) => {
|
||||
self.write("</a>")?;
|
||||
}
|
||||
Tag::Image(_, _, _) => (), // shouldn't happen, handled in start
|
||||
Tag::FootnoteDefinition(_) => {
|
||||
self.write("</div>\n")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_text(&mut self, text: &str) -> io::Result<()> {
|
||||
struct EmojiParser<'a> {
|
||||
text: &'a str,
|
||||
position: usize,
|
||||
}
|
||||
|
||||
enum Token<'a> {
|
||||
Text(&'a str),
|
||||
Emoji(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> EmojiParser<'a> {
|
||||
fn current(&self) -> Option<char> {
|
||||
self.text[self.position..].chars().next()
|
||||
}
|
||||
|
||||
fn next_token(&mut self) -> Option<Token<'a>> {
|
||||
match self.current() {
|
||||
Some(':') => {
|
||||
let text_start = self.position;
|
||||
self.position += 1;
|
||||
if self.current().is_some_and(|c| c.is_alphabetic()) {
|
||||
let name_start = self.position;
|
||||
while let Some(c) = self.current() {
|
||||
if c.is_alphanumeric() || c == '_' {
|
||||
self.position += c.len_utf8();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.current() == Some(':') {
|
||||
let name_end = self.position;
|
||||
self.position += 1;
|
||||
Some(Token::Emoji(&self.text[name_start..name_end]))
|
||||
} else {
|
||||
Some(Token::Text(&self.text[text_start..self.position]))
|
||||
}
|
||||
} else {
|
||||
Some(Token::Text(&self.text[text_start..self.position]))
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
let start = self.position;
|
||||
while let Some(c) = self.current() {
|
||||
if c == ':' {
|
||||
break;
|
||||
} else {
|
||||
self.position += c.len_utf8();
|
||||
}
|
||||
}
|
||||
let end = self.position;
|
||||
Some(Token::Text(&self.text[start..end]))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let CodeBlockState::InCodeBlock(language) = &self.code_block_state {
|
||||
let code_block_mode = language
|
||||
.as_ref()
|
||||
.map(|language| CodeBlockMode::parse(language));
|
||||
let highlighting_language = code_block_mode
|
||||
.as_ref()
|
||||
.and_then(|mode| mode.highlighting_language());
|
||||
let syntax =
|
||||
highlighting_language.and_then(|language| self.config.syntaxes.get(language));
|
||||
if let Some(syntax) = syntax {
|
||||
highlight(&mut self.writer, syntax, text)?;
|
||||
} else {
|
||||
escape_html(&mut self.writer, text)?;
|
||||
}
|
||||
} else {
|
||||
let mut parser = EmojiParser { text, position: 0 };
|
||||
while let Some(token) = parser.next_token() {
|
||||
match token {
|
||||
Token::Text(text) => escape_html(&mut self.writer, text)?,
|
||||
Token::Emoji(name) => {
|
||||
if let Some(filename) = self.config.emoji.get(name) {
|
||||
let branch_id = self
|
||||
.treehouse
|
||||
.branches_by_named_id
|
||||
.get(&format!("emoji/{name}"))
|
||||
.copied();
|
||||
if let Some(branch) = branch_id.map(|id| self.treehouse.tree.branch(id))
|
||||
{
|
||||
self.writer.write_str("<a href=\"")?;
|
||||
escape_html(&mut self.writer, &self.config.site)?;
|
||||
self.writer.write_str("/b?")?;
|
||||
escape_html(&mut self.writer, &branch.attributes.id)?;
|
||||
self.writer.write_str("\">")?;
|
||||
}
|
||||
|
||||
self.writer
|
||||
.write_str("<img data-cast=\"emoji\" title=\":")?;
|
||||
escape_html(&mut self.writer, name)?;
|
||||
self.writer.write_str(":\" src=\"")?;
|
||||
let url = self
|
||||
.config_derived_data
|
||||
.static_urls
|
||||
.get(&format!("emoji/{filename}"))
|
||||
.unwrap_or_default();
|
||||
escape_html(&mut self.writer, &url)?;
|
||||
self.writer.write_str("\" alt=\"")?;
|
||||
escape_html(&mut self.writer, name)?;
|
||||
if let Some(image_size) = self
|
||||
.config_derived_data
|
||||
.image_size(&format!("static/emoji/{filename}"))
|
||||
{
|
||||
write!(
|
||||
self.writer,
|
||||
"\" width=\"{}\" height=\"{}",
|
||||
image_size.width, image_size.height
|
||||
)?;
|
||||
}
|
||||
self.writer.write_str("\">")?;
|
||||
|
||||
if branch_id.is_some() {
|
||||
self.writer.write_str("</a>")?;
|
||||
}
|
||||
} else {
|
||||
self.writer.write_str(":")?;
|
||||
escape_html(&mut self.writer, name)?;
|
||||
self.writer.write_str(":")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// run raw text, consuming end tag
|
||||
fn raw_text(&mut self) -> io::Result<()> {
|
||||
let mut nest = 0;
|
||||
while let Some(event) = self.iter.next() {
|
||||
match event {
|
||||
Start(_) => nest += 1,
|
||||
End(_) => {
|
||||
if nest == 0 {
|
||||
break;
|
||||
}
|
||||
nest -= 1;
|
||||
}
|
||||
Html(text) | Code(text) | Text(text) => {
|
||||
escape_html(&mut self.writer, &text)?;
|
||||
self.end_newline = text.ends_with('\n');
|
||||
}
|
||||
SoftBreak | HardBreak | Rule => {
|
||||
self.write(" ")?;
|
||||
}
|
||||
FootnoteReference(name) => {
|
||||
let len = self.numbers.len() + 1;
|
||||
let number = *self.numbers.entry(name).or_insert(len);
|
||||
write!(&mut self.writer, "[{}]", number)?;
|
||||
}
|
||||
TaskListMarker(true) => self.write("[x]")?,
|
||||
TaskListMarker(false) => self.write("[ ]")?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum LiterateCodeKind<'a> {
|
||||
Input,
|
||||
Output { placeholder_pic_id: &'a str },
|
||||
}
|
||||
|
||||
enum CodeBlockMode<'a> {
|
||||
PlainText,
|
||||
SyntaxHighlightOnly {
|
||||
language: &'a str,
|
||||
},
|
||||
LiterateProgram {
|
||||
language: &'a str,
|
||||
kind: LiterateCodeKind<'a>,
|
||||
program_name: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a> CodeBlockMode<'a> {
|
||||
fn parse(language: &'a str) -> CodeBlockMode<'a> {
|
||||
if language.is_empty() {
|
||||
CodeBlockMode::PlainText
|
||||
} else if let Some((language, program_name)) = language.split_once(' ') {
|
||||
let (program_name, placeholder_pic_id) =
|
||||
program_name.split_once(' ').unwrap_or((program_name, ""));
|
||||
CodeBlockMode::LiterateProgram {
|
||||
language,
|
||||
kind: if language == "output" {
|
||||
LiterateCodeKind::Output { placeholder_pic_id }
|
||||
} else {
|
||||
LiterateCodeKind::Input
|
||||
},
|
||||
program_name: program_name.split(' ').next().unwrap(),
|
||||
}
|
||||
} else {
|
||||
CodeBlockMode::SyntaxHighlightOnly { language }
|
||||
}
|
||||
}
|
||||
|
||||
fn highlighting_language(&self) -> Option<&str> {
|
||||
if let CodeBlockMode::LiterateProgram { language, .. }
|
||||
| CodeBlockMode::SyntaxHighlightOnly { language } = self
|
||||
{
|
||||
Some(language)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
|
||||
/// push it to a `String`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use pulldown_cmark::{html, Parser};
|
||||
///
|
||||
/// let markdown_str = r#"
|
||||
/// hello
|
||||
/// =====
|
||||
///
|
||||
/// * alpha
|
||||
/// * beta
|
||||
/// "#;
|
||||
/// let parser = Parser::new(markdown_str);
|
||||
///
|
||||
/// let mut html_buf = String::new();
|
||||
/// html::push_html(&mut html_buf, parser);
|
||||
///
|
||||
/// assert_eq!(html_buf, r#"<h1>hello</h1>
|
||||
/// <ul>
|
||||
/// <li>alpha</li>
|
||||
/// <li>beta</li>
|
||||
/// </ul>
|
||||
/// "#);
|
||||
/// ```
|
||||
pub fn push_html<'a, I>(
|
||||
s: &mut String,
|
||||
treehouse: &'a Treehouse,
|
||||
config: &'a Config,
|
||||
config_derived_data: &'a mut ConfigDerivedData,
|
||||
page_id: &'a str,
|
||||
iter: I,
|
||||
) where
|
||||
I: Iterator<Item = Event<'a>>,
|
||||
{
|
||||
HtmlWriter::new(treehouse, config, config_derived_data, page_id, iter, s)
|
||||
.run()
|
||||
.unwrap();
|
||||
}
|
|
@ -1,72 +1,82 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
state::Treehouse,
|
||||
tree::{attributes::Content, SemaBranchId},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NavigationMapBuilder {
|
||||
stack: Vec<String>,
|
||||
navigation_map: NavigationMap,
|
||||
}
|
||||
|
||||
impl NavigationMapBuilder {
|
||||
fn enter_tree(&mut self, tree: String) {
|
||||
self.stack.push(tree.clone());
|
||||
self.navigation_map.paths.insert(tree, self.stack.clone());
|
||||
}
|
||||
|
||||
fn exit_tree(&mut self) {
|
||||
self.stack.pop();
|
||||
}
|
||||
|
||||
fn finish(self) -> NavigationMap {
|
||||
self.navigation_map
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct NavigationMap {
|
||||
/// Tells you which pages need to be opened to get to the key.
|
||||
pub paths: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl NavigationMap {
|
||||
pub fn build(treehouse: &Treehouse, root_tree_path: &str) -> Self {
|
||||
let mut builder = NavigationMapBuilder::default();
|
||||
|
||||
fn rec_branch(
|
||||
treehouse: &Treehouse,
|
||||
builder: &mut NavigationMapBuilder,
|
||||
branch_id: SemaBranchId,
|
||||
) {
|
||||
let branch = treehouse.tree.branch(branch_id);
|
||||
if let Content::Link(linked) = &branch.attributes.content {
|
||||
rec_tree(treehouse, builder, linked);
|
||||
} else {
|
||||
for &child_id in &branch.children {
|
||||
rec_branch(treehouse, builder, child_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rec_tree(treehouse: &Treehouse, builder: &mut NavigationMapBuilder, tree_path: &str) {
|
||||
if let Some(roots) = treehouse.roots.get(tree_path) {
|
||||
// Pages can link to each other causing infinite recursion, so we need to handle that
|
||||
// case by skipping pages that already have been analyzed.
|
||||
if !builder.navigation_map.paths.contains_key(tree_path) {
|
||||
builder.enter_tree(tree_path.to_owned());
|
||||
for &branch_id in &roots.branches {
|
||||
rec_branch(treehouse, builder, branch_id);
|
||||
}
|
||||
builder.exit_tree();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec_tree(treehouse, &mut builder, root_tree_path);
|
||||
|
||||
builder.finish()
|
||||
pub fn to_javascript(&self) -> String {
|
||||
format!(
|
||||
"export const navigationMap = {};",
|
||||
serde_json::to_string(&self.paths)
|
||||
.expect("serialization of the navigation map should not fail")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NavigationMapBuilder {
|
||||
stack: Vec<String>,
|
||||
navigation_map: NavigationMap,
|
||||
}
|
||||
|
||||
impl NavigationMapBuilder {
|
||||
pub fn enter_tree(&mut self, tree: String) {
|
||||
self.stack.push(tree.clone());
|
||||
self.navigation_map.paths.insert(tree, self.stack.clone());
|
||||
}
|
||||
|
||||
pub fn exit_tree(&mut self) {
|
||||
self.stack.pop();
|
||||
}
|
||||
|
||||
pub fn finish(self) -> NavigationMap {
|
||||
self.navigation_map
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_navigation_map(treehouse: &Treehouse, root_tree_path: &str) -> NavigationMap {
|
||||
let mut builder = NavigationMapBuilder::default();
|
||||
|
||||
fn rec_branch(
|
||||
treehouse: &Treehouse,
|
||||
builder: &mut NavigationMapBuilder,
|
||||
branch_id: SemaBranchId,
|
||||
) {
|
||||
let branch = treehouse.tree.branch(branch_id);
|
||||
if let Content::Link(linked) = &branch.attributes.content {
|
||||
rec_tree(treehouse, builder, linked);
|
||||
} else {
|
||||
for &child_id in &branch.children {
|
||||
rec_branch(treehouse, builder, child_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rec_tree(treehouse: &Treehouse, builder: &mut NavigationMapBuilder, tree_path: &str) {
|
||||
if let Some(roots) = treehouse.roots.get(tree_path) {
|
||||
// Pages can link to each other causing infinite recursion, so we need to handle that
|
||||
// case by skipping pages that already have been analyzed.
|
||||
if !builder.navigation_map.paths.contains_key(tree_path) {
|
||||
builder.enter_tree(tree_path.to_owned());
|
||||
for &branch_id in &roots.branches {
|
||||
rec_branch(treehouse, builder, branch_id);
|
||||
}
|
||||
builder.exit_tree();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec_tree(treehouse, &mut builder, root_tree_path);
|
||||
|
||||
builder.finish()
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
use std::{borrow::Cow, fmt::Write};
|
||||
|
||||
use pulldown_cmark::{BrokenLink, LinkType};
|
||||
use treehouse_format::pull::BranchKind;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
dirs::Dirs,
|
||||
cli::Paths,
|
||||
config::{Config, ConfigDerivedData, Markup},
|
||||
html::EscapeAttribute,
|
||||
state::{FileId, Treehouse},
|
||||
tree::{
|
||||
|
@ -13,13 +14,14 @@ use crate::{
|
|||
},
|
||||
};
|
||||
|
||||
use super::{djot, EscapeHtml};
|
||||
use super::{djot, markdown, EscapeHtml};
|
||||
|
||||
pub fn branch_to_html(
|
||||
s: &mut String,
|
||||
treehouse: &Treehouse,
|
||||
treehouse: &mut Treehouse,
|
||||
config: &Config,
|
||||
dirs: &Dirs,
|
||||
config_derived_data: &mut ConfigDerivedData,
|
||||
paths: &Paths<'_>,
|
||||
file_id: FileId,
|
||||
branch_id: SemaBranchId,
|
||||
) {
|
||||
|
@ -113,28 +115,87 @@ pub fn branch_to_html(
|
|||
final_markup.push('\n');
|
||||
}
|
||||
|
||||
let broken_link_callback = &mut |broken_link: BrokenLink<'_>| {
|
||||
if let LinkType::Reference | LinkType::Shortcut = broken_link.link_type {
|
||||
broken_link
|
||||
.reference
|
||||
.split_once(':')
|
||||
.and_then(|(kind, linked)| match kind {
|
||||
"def" => config
|
||||
.defs
|
||||
.get(linked)
|
||||
.map(|link| (link.clone().into(), "".into())),
|
||||
"branch" => treehouse
|
||||
.branches_by_named_id
|
||||
.get(linked)
|
||||
.map(|&branch_id| {
|
||||
(
|
||||
format!(
|
||||
"{}/b?{}",
|
||||
config.site,
|
||||
treehouse.tree.branch(branch_id).attributes.id
|
||||
)
|
||||
.into(),
|
||||
"".into(),
|
||||
)
|
||||
}),
|
||||
"page" => Some((config.page_url(linked).into(), "".into())),
|
||||
"pic" => config.pics.get(linked).map(|filename| {
|
||||
(
|
||||
// NOTE: We can't generate a URL with a hash here yet, because we
|
||||
// cannot access ConfigDerivedData here due to it being borrowed
|
||||
// by the Markdown parser.
|
||||
format!("{}/static/pic/{}", config.site, &filename).into(),
|
||||
"".into(),
|
||||
)
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if branch.attributes.template {
|
||||
final_markup = mini_template::render(config, treehouse, dirs, &final_markup);
|
||||
final_markup = mini_template::render(config, treehouse, paths, &final_markup);
|
||||
}
|
||||
s.push_str("<th-bc>");
|
||||
match config.markup {
|
||||
Markup::Markdown => {
|
||||
let markdown_parser = pulldown_cmark::Parser::new_with_broken_link_callback(
|
||||
&final_markup,
|
||||
{
|
||||
use pulldown_cmark::Options;
|
||||
Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES
|
||||
},
|
||||
Some(broken_link_callback),
|
||||
);
|
||||
markdown::push_html(
|
||||
s,
|
||||
treehouse,
|
||||
config,
|
||||
config_derived_data,
|
||||
treehouse.tree_path(file_id).expect(".tree file expected"),
|
||||
markdown_parser,
|
||||
)
|
||||
}
|
||||
Markup::Djot => {
|
||||
let events: Vec<_> = jotdown::Parser::new(&final_markup)
|
||||
.into_offset_iter()
|
||||
.collect();
|
||||
let render_diagnostics = djot::Renderer {
|
||||
page_id: treehouse
|
||||
.tree_path(file_id)
|
||||
.expect(".tree file expected")
|
||||
.to_owned(),
|
||||
|
||||
let events: Vec<_> = jotdown::Parser::new(&final_markup)
|
||||
.into_offset_iter()
|
||||
.collect();
|
||||
// TODO: Report rendering diagnostics.
|
||||
let render_diagnostics = djot::Renderer {
|
||||
page_id: treehouse
|
||||
.tree_path(file_id)
|
||||
.expect(".tree file expected")
|
||||
.to_owned(),
|
||||
|
||||
config,
|
||||
dirs,
|
||||
|
||||
treehouse,
|
||||
file_id,
|
||||
}
|
||||
.render(&events, s);
|
||||
config,
|
||||
config_derived_data,
|
||||
treehouse,
|
||||
file_id,
|
||||
}
|
||||
.render(&events, s);
|
||||
}
|
||||
};
|
||||
|
||||
let branch = treehouse.tree.branch(branch_id);
|
||||
if let Content::Link(link) = &branch.attributes.content {
|
||||
|
@ -186,7 +247,15 @@ pub fn branch_to_html(
|
|||
let num_children = branch.children.len();
|
||||
for i in 0..num_children {
|
||||
let child_id = treehouse.tree.branch(branch_id).children[i];
|
||||
branch_to_html(s, treehouse, config, dirs, file_id, child_id);
|
||||
branch_to_html(
|
||||
s,
|
||||
treehouse,
|
||||
config,
|
||||
config_derived_data,
|
||||
paths,
|
||||
file_id,
|
||||
child_id,
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
}
|
||||
|
@ -200,15 +269,24 @@ pub fn branch_to_html(
|
|||
|
||||
pub fn branches_to_html(
|
||||
s: &mut String,
|
||||
treehouse: &Treehouse,
|
||||
treehouse: &mut Treehouse,
|
||||
config: &Config,
|
||||
dirs: &Dirs,
|
||||
config_derived_data: &mut ConfigDerivedData,
|
||||
paths: &Paths<'_>,
|
||||
file_id: FileId,
|
||||
branches: &[SemaBranchId],
|
||||
) {
|
||||
s.push_str("<ul>");
|
||||
for &child in branches {
|
||||
branch_to_html(s, treehouse, config, dirs, file_id, child);
|
||||
branch_to_html(
|
||||
s,
|
||||
treehouse,
|
||||
config,
|
||||
config_derived_data,
|
||||
paths,
|
||||
file_id,
|
||||
child,
|
||||
);
|
||||
}
|
||||
s.push_str("</ul>");
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
use std::ops::ControlFlow;
|
||||
use std::{ffi::OsStr, path::PathBuf};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use log::warn;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::vfs::{self, Dir, VPathBuf};
|
||||
use crate::static_urls::StaticUrls;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ImportMap {
|
||||
|
@ -13,30 +15,49 @@ pub struct ImportMap {
|
|||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ImportRoot {
|
||||
pub name: String,
|
||||
pub path: VPathBuf,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl ImportMap {
|
||||
pub fn generate(site: &str, root: &dyn Dir, import_roots: &[ImportRoot]) -> Self {
|
||||
pub fn generate(base_url: String, import_roots: &[ImportRoot]) -> Self {
|
||||
let mut import_map = ImportMap {
|
||||
imports: IndexMap::new(),
|
||||
};
|
||||
|
||||
for import_root in import_roots {
|
||||
vfs::walk_dir_rec(root, &import_root.path, &mut |path| {
|
||||
if path.extension() == Some("js") {
|
||||
import_map.imports.insert(
|
||||
format!(
|
||||
"{}/{}",
|
||||
import_root.name,
|
||||
path.strip_prefix(&import_root.path).unwrap_or(path)
|
||||
),
|
||||
vfs::url(site, root, path)
|
||||
.expect("import directory is not anchored anywhere"),
|
||||
);
|
||||
for root in import_roots {
|
||||
let static_urls = StaticUrls::new(
|
||||
PathBuf::from(&root.path),
|
||||
format!("{base_url}/{}", root.path),
|
||||
);
|
||||
for entry in WalkDir::new(&root.path) {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
warn!("directory walk failed: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !entry.file_type().is_dir() && entry.path().extension() == Some(OsStr::new("js"))
|
||||
{
|
||||
let normalized_path = entry
|
||||
.path()
|
||||
.strip_prefix(&root.path)
|
||||
.unwrap_or(entry.path())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
match static_urls.get(&normalized_path) {
|
||||
Ok(url) => {
|
||||
import_map
|
||||
.imports
|
||||
.insert(format!("{}/{normalized_path}", root.name), url);
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("could not get static url for {normalized_path}: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
import_map.imports.sort_unstable_keys();
|
||||
|
|
28
crates/treehouse/src/include_static.rs
Normal file
28
crates/treehouse/src/include_static.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct IncludeStatic {
|
||||
pub base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl HelperDef for IncludeStatic {
|
||||
fn call_inner<'reg: 'rc, 'rc>(
|
||||
&self,
|
||||
helper: &Helper<'reg, 'rc>,
|
||||
_: &'reg Handlebars<'reg>,
|
||||
_: &'rc Context,
|
||||
_: &mut RenderContext<'reg, 'rc>,
|
||||
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
|
||||
if let Some(param) = helper.param(0).and_then(|v| v.value().as_str()) {
|
||||
return Ok(ScopedJson::Derived(Value::String(
|
||||
std::fs::read_to_string(self.base_dir.join(param)).map_err(|error| {
|
||||
RenderError::new(format!("cannot read static asset {param}: {error}"))
|
||||
})?,
|
||||
)));
|
||||
}
|
||||
|
||||
Err(RenderError::new("asset path must be provided"))
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod dirs;
|
||||
pub mod fun;
|
||||
pub mod generate;
|
||||
pub mod history;
|
||||
pub mod html;
|
||||
pub mod import_map;
|
||||
pub mod parse;
|
||||
pub mod paths;
|
||||
pub mod state;
|
||||
pub mod tree;
|
||||
pub mod vfs;
|
|
@ -1,83 +1,71 @@
|
|||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
use log::error;
|
||||
use treehouse::cli::serve::serve;
|
||||
use treehouse::dirs::Dirs;
|
||||
use treehouse::generate::{self, Sources};
|
||||
use treehouse::vfs::asynch::AsyncDir;
|
||||
use treehouse::vfs::{
|
||||
AnchoredAtExt, Blake3ContentVersionCache, DynDir, ImageSizeCache, ToDynDir, VPathBuf,
|
||||
};
|
||||
use treehouse::vfs::{Cd, PhysicalDir};
|
||||
use treehouse::{
|
||||
cli::{
|
||||
fix::{fix_all_cli, fix_file_cli},
|
||||
wc::wc_cli,
|
||||
Command, ProgramArgs,
|
||||
},
|
||||
vfs::{BufferedFile, MemDir, VPath},
|
||||
use cli::{
|
||||
fix::{fix_all_cli, fix_file_cli},
|
||||
serve::serve,
|
||||
wc::wc_cli,
|
||||
Command, Paths, ProgramArgs,
|
||||
};
|
||||
use generate::{regenerate_or_report_error, LatestRevision};
|
||||
use log::{error, info, warn};
|
||||
|
||||
fn vfs_sources() -> anyhow::Result<DynDir> {
|
||||
let mut root = MemDir::new();
|
||||
|
||||
root.add(
|
||||
VPath::new("treehouse.toml"),
|
||||
BufferedFile::new(fs::read("treehouse.toml")?).to_dyn(),
|
||||
);
|
||||
root.add(
|
||||
VPath::new("static"),
|
||||
PhysicalDir::new(PathBuf::from("static"))
|
||||
.anchored_at(VPathBuf::new("static"))
|
||||
.to_dyn(),
|
||||
);
|
||||
root.add(
|
||||
VPath::new("template"),
|
||||
PhysicalDir::new(PathBuf::from("template")).to_dyn(),
|
||||
);
|
||||
root.add(
|
||||
VPath::new("content"),
|
||||
PhysicalDir::new(PathBuf::from("content")).to_dyn(),
|
||||
);
|
||||
|
||||
let root = Blake3ContentVersionCache::new(root);
|
||||
let root = ImageSizeCache::new(root);
|
||||
|
||||
Ok(root.to_dyn())
|
||||
}
|
||||
mod cli;
|
||||
mod config;
|
||||
mod fun;
|
||||
mod generate;
|
||||
mod history;
|
||||
mod html;
|
||||
mod import_map;
|
||||
mod include_static;
|
||||
mod parse;
|
||||
mod paths;
|
||||
mod state;
|
||||
mod static_urls;
|
||||
mod tree;
|
||||
|
||||
async fn fallible_main() -> anyhow::Result<()> {
|
||||
let args = ProgramArgs::parse();
|
||||
|
||||
let src = vfs_sources()?;
|
||||
let dirs = Arc::new(Dirs {
|
||||
root: src.clone(),
|
||||
content: Cd::new(src.clone(), VPathBuf::new("content")).to_dyn(),
|
||||
static_: Cd::new(src.clone(), VPathBuf::new("static")).to_dyn(),
|
||||
template: Cd::new(src.clone(), VPathBuf::new("template")).to_dyn(),
|
||||
pic: Cd::new(src.clone(), VPathBuf::new("static/pic")).to_dyn(),
|
||||
emoji: Cd::new(src.clone(), VPathBuf::new("static/emoji")).to_dyn(),
|
||||
syntax: Cd::new(src.clone(), VPathBuf::new("static/syntax")).to_dyn(),
|
||||
});
|
||||
let paths = Paths {
|
||||
target_dir: Path::new("target/site"),
|
||||
template_target_dir: Path::new("target/site/static/html"),
|
||||
|
||||
config_file: Path::new("treehouse.toml"),
|
||||
|
||||
// NOTE: These are intentionally left unconfigurable from within treehouse.toml
|
||||
// because this is is one of those things that should be consistent between sites.
|
||||
static_dir: Path::new("static"),
|
||||
template_dir: Path::new("template"),
|
||||
content_dir: Path::new("content"),
|
||||
};
|
||||
|
||||
match args.command {
|
||||
Command::Generate(generate_args) => {
|
||||
info!("regenerating using directories: {paths:#?}");
|
||||
let latest_revision = match generate_args.commits_only {
|
||||
true => LatestRevision::LatestCommit,
|
||||
false => LatestRevision::WorkingTree,
|
||||
};
|
||||
regenerate_or_report_error(&paths, latest_revision)?;
|
||||
warn!("`generate` is for debugging only and the files cannot be fully served using a static file server; use `treehouse serve` if you wish to start a treehouse server");
|
||||
}
|
||||
Command::Serve {
|
||||
generate: _,
|
||||
generate: generate_args,
|
||||
serve: serve_args,
|
||||
} => {
|
||||
let sources = Arc::new(Sources::load(&dirs).context("failed to load sources")?);
|
||||
let target = generate::target(dirs, sources.clone());
|
||||
serve(sources, AsyncDir::new(target), serve_args.port).await?;
|
||||
let latest_revision = match generate_args.commits_only {
|
||||
true => LatestRevision::LatestCommit,
|
||||
false => LatestRevision::WorkingTree,
|
||||
};
|
||||
let (config, treehouse) = regenerate_or_report_error(&paths, latest_revision)?;
|
||||
serve(config, treehouse, &paths, serve_args.port).await?;
|
||||
}
|
||||
|
||||
Command::Fix(fix_args) => fix_file_cli(fix_args, &*dirs.content)?.apply().await?,
|
||||
Command::FixAll(fix_args) => fix_all_cli(fix_args, &*dirs.content)?.apply().await?,
|
||||
Command::Fix(fix_args) => fix_file_cli(fix_args)?,
|
||||
Command::FixAll(fix_args) => fix_all_cli(fix_args, &paths)?,
|
||||
|
||||
Command::Wc(wc_args) => wc_cli(&dirs.content, wc_args)?,
|
||||
Command::Wc(wc_args) => wc_cli(paths.content_dir, wc_args)?,
|
||||
|
||||
Command::Ulid => {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use std::{collections::HashMap, ops::Range};
|
||||
use std::{collections::HashMap, ops::Range, path::PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use codespan_reporting::{
|
||||
|
@ -6,19 +6,26 @@ use codespan_reporting::{
|
|||
files::SimpleFiles,
|
||||
term::termcolor::{ColorChoice, StandardStream},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{
|
||||
tree::{SemaBranchId, SemaRoots, SemaTree},
|
||||
vfs::VPathBuf,
|
||||
};
|
||||
use crate::tree::{SemaBranchId, SemaRoots, SemaTree};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RevisionInfo {
|
||||
pub is_latest: bool,
|
||||
pub number: usize,
|
||||
pub commit: String,
|
||||
pub commit_short: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Source {
|
||||
Tree {
|
||||
input: String,
|
||||
tree_path: String,
|
||||
target_path: VPathBuf,
|
||||
target_path: PathBuf,
|
||||
revision_info: RevisionInfo,
|
||||
},
|
||||
Other(String),
|
||||
}
|
||||
|
@ -96,6 +103,13 @@ impl Treehouse {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn revision_info(&self, file_id: FileId) -> Option<&RevisionInfo> {
|
||||
match self.source(file_id) {
|
||||
Source::Tree { revision_info, .. } => Some(revision_info),
|
||||
Source::Other(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_missingno(&mut self) -> Ulid {
|
||||
self.missingno_generator
|
||||
.generate()
|
||||
|
@ -103,12 +117,6 @@ impl Treehouse {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for Treehouse {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TomlError {
|
||||
pub message: String,
|
||||
pub span: Option<Range<usize>>,
|
||||
|
|
89
crates/treehouse/src/static_urls.rs
Normal file
89
crates/treehouse/src/static_urls.rs
Normal file
|
@ -0,0 +1,89 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::{self, BufReader},
|
||||
path::PathBuf,
|
||||
sync::{Mutex, RwLock},
|
||||
};
|
||||
|
||||
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct StaticUrls {
|
||||
base_dir: PathBuf,
|
||||
base_url: String,
|
||||
// Really annoying that we have to use an RwLock for this. We only ever generate in a
|
||||
// single-threaded environment.
|
||||
// Honestly it would be a lot more efficient if Handlebars just assumed single-threadedness
|
||||
// and required you to clone it over to different threads.
|
||||
// Stuff like this is why I really want to implement my own templating engine...
|
||||
hash_cache: RwLock<HashMap<String, String>>,
|
||||
missing_files: Mutex<Vec<MissingFile>>,
|
||||
}
|
||||
|
||||
pub struct MissingFile {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl StaticUrls {
|
||||
pub fn new(base_dir: PathBuf, base_url: String) -> Self {
|
||||
Self {
|
||||
base_dir,
|
||||
base_url,
|
||||
hash_cache: RwLock::new(HashMap::new()),
|
||||
missing_files: Mutex::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, filename: &str) -> Result<String, io::Error> {
|
||||
let hash_cache = self.hash_cache.read().unwrap();
|
||||
if let Some(cached) = hash_cache.get(filename) {
|
||||
return Ok(cached.to_owned());
|
||||
}
|
||||
drop(hash_cache);
|
||||
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
let file = BufReader::new(File::open(self.base_dir.join(filename))?);
|
||||
hasher.update_reader(file)?;
|
||||
// NOTE: Here the hash is truncated to 8 characters. This is fine, because we don't
|
||||
// care about security here - only detecting changes in files.
|
||||
let hash = format!(
|
||||
"{}/{}?cache=b3-{}",
|
||||
self.base_url,
|
||||
filename,
|
||||
&hasher.finalize().to_hex()[0..8]
|
||||
);
|
||||
{
|
||||
let mut hash_cache = self.hash_cache.write().unwrap();
|
||||
hash_cache.insert(filename.to_owned(), hash.clone());
|
||||
}
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub fn take_missing_files(&self) -> Vec<MissingFile> {
|
||||
std::mem::take(&mut self.missing_files.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl HelperDef for StaticUrls {
|
||||
fn call_inner<'reg: 'rc, 'rc>(
|
||||
&self,
|
||||
helper: &Helper<'reg, 'rc>,
|
||||
_: &'reg Handlebars<'reg>,
|
||||
_: &'rc Context,
|
||||
_: &mut RenderContext<'reg, 'rc>,
|
||||
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
|
||||
if let Some(param) = helper.param(0).and_then(|v| v.value().as_str()) {
|
||||
return Ok(ScopedJson::Derived(Value::String(
|
||||
self.get(param).unwrap_or_else(|_| {
|
||||
self.missing_files.lock().unwrap().push(MissingFile {
|
||||
path: param.to_owned(),
|
||||
});
|
||||
format!("{}/{}", self.base_url, param)
|
||||
}),
|
||||
)));
|
||||
}
|
||||
|
||||
Err(RenderError::new("asset path must be provided"))
|
||||
}
|
||||
}
|
|
@ -163,7 +163,13 @@ impl SemaBranch {
|
|||
) -> SemaBranchId {
|
||||
let attributes = Self::parse_attributes(treehouse, diagnostics, file_id, &branch);
|
||||
|
||||
let named_id = attributes.id.to_owned();
|
||||
let revision_info = treehouse
|
||||
.revision_info(file_id)
|
||||
.expect(".tree files must have Tree-type sources");
|
||||
let named_id = match revision_info.is_latest {
|
||||
true => attributes.id.to_owned(),
|
||||
false => format!("{}@{}", attributes.id, revision_info.commit_short),
|
||||
};
|
||||
let html_id = format!(
|
||||
"{}:{}",
|
||||
treehouse.tree_path(file_id).unwrap(),
|
||||
|
|
|
@ -4,16 +4,11 @@
|
|||
//! for injecting *custom, stateful* context into the renderer, which is important for things like
|
||||
//! the `pic` template to work.
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
dirs::Dirs,
|
||||
html::EscapeHtml,
|
||||
state::Treehouse,
|
||||
vfs::{Dir, VPath},
|
||||
};
|
||||
use pulldown_cmark::escape::escape_html;
|
||||
|
||||
use crate::{cli::Paths, config::Config, state::Treehouse};
|
||||
|
||||
struct Lexer<'a> {
|
||||
input: &'a str,
|
||||
|
@ -149,12 +144,12 @@ struct Renderer<'a> {
|
|||
|
||||
struct InvalidTemplate;
|
||||
|
||||
impl Renderer<'_> {
|
||||
impl<'a> Renderer<'a> {
|
||||
fn emit_token_verbatim(&mut self, token: &Token) {
|
||||
self.output.push_str(&self.lexer.input[token.range.clone()]);
|
||||
}
|
||||
|
||||
fn render(&mut self, config: &Config, treehouse: &Treehouse, dirs: &Dirs) {
|
||||
fn render(&mut self, config: &Config, treehouse: &Treehouse, paths: &Paths<'_>) {
|
||||
let kind_of = |token: &Token| token.kind;
|
||||
|
||||
while let Some(token) = self.lexer.next() {
|
||||
|
@ -171,12 +166,12 @@ impl Renderer<'_> {
|
|||
match Self::render_template(
|
||||
config,
|
||||
treehouse,
|
||||
dirs,
|
||||
paths,
|
||||
self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(),
|
||||
) {
|
||||
Ok(s) => match escaping {
|
||||
EscapingMode::EscapeHtml => {
|
||||
_ = write!(self.output, "{}", EscapeHtml(&s));
|
||||
_ = escape_html(&mut self.output, &s);
|
||||
}
|
||||
EscapingMode::NoEscaping => self.output.push_str(&s),
|
||||
},
|
||||
|
@ -198,27 +193,24 @@ impl Renderer<'_> {
|
|||
fn render_template(
|
||||
config: &Config,
|
||||
_treehouse: &Treehouse,
|
||||
dirs: &Dirs,
|
||||
paths: &Paths<'_>,
|
||||
template: &str,
|
||||
) -> Result<String, InvalidTemplate> {
|
||||
let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
|
||||
match function {
|
||||
"pic" => Ok(config.pic_url(&*dirs.pic, arguments)),
|
||||
"include_static" => VPath::try_new(arguments)
|
||||
.ok()
|
||||
.and_then(|vpath| dirs.static_.content(vpath))
|
||||
.and_then(|content| String::from_utf8(content).ok())
|
||||
.ok_or(InvalidTemplate),
|
||||
"pic" => Ok(config.pic_url(arguments)),
|
||||
"include_static" => std::fs::read_to_string(paths.static_dir.join(arguments))
|
||||
.map_err(|_| InvalidTemplate),
|
||||
_ => Err(InvalidTemplate),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(config: &Config, treehouse: &Treehouse, dirs: &Dirs, input: &str) -> String {
|
||||
pub fn render(config: &Config, treehouse: &Treehouse, paths: &Paths<'_>, input: &str) -> String {
|
||||
let mut renderer = Renderer {
|
||||
lexer: Lexer::new(input),
|
||||
output: String::new(),
|
||||
};
|
||||
renderer.render(config, treehouse, dirs);
|
||||
renderer.render(config, treehouse, paths);
|
||||
renderer.output
|
||||
}
|
||||
|
|
|
@ -1,247 +0,0 @@
|
|||
//! The treehouse virtual file system.
|
||||
//!
|
||||
//! Unlike traditional file systems, there is no separation between directories and files.
|
||||
//! Instead, our file system is based on _entries_, which may have specific, optional, well-typed
|
||||
//! metadata attached to them.
|
||||
//! A directory is formed by returning a list of paths from [`dir`][Dir::dir], and a file is
|
||||
//! formed by returning `Some` from [`content`][Dir::content].
|
||||
//!
|
||||
//! This makes using the file system simpler, as you do not have to differentiate between different
|
||||
//! entry kinds. All paths act as if they _could_ return byte content, and all paths act as if they
|
||||
//! _could_ have children.
|
||||
//!
|
||||
//! # Composability
|
||||
//!
|
||||
//! [`Dir`]s are composable. The [`Dir`] itself starts off with the root path ([`VPath::ROOT`]),
|
||||
//! which may contain further [`dir`][Dir::dir] entries, or content by itself.
|
||||
//! This makes it possible to nest a [`Dir`] under another [`Dir`].
|
||||
//!
|
||||
//! Additionally, there's also the inverse operation, [`Cd`] (named after the `cd`
|
||||
//! _change directory_ shell command), which returns a [`Dir`] viewing a subpath within another
|
||||
//! [`Dir`].
|
||||
//!
|
||||
//! # Building directories
|
||||
//!
|
||||
//! In-memory directories can be composed using the following primitives:
|
||||
//!
|
||||
//! - [`EmptyEntry`] - has no metadata whatsoever.
|
||||
//! - [`BufferedFile`] - root path content is the provided byte vector.
|
||||
//! - [`MemDir`] - a [`Dir`] containing a single level of other [`Dir`]s inside.
|
||||
//!
|
||||
//! Additionally, for interfacing with the OS file system, [`PhysicalDir`] is available,
|
||||
//! representing a directory stored on the disk.
|
||||
//!
|
||||
//! # Virtual paths
|
||||
//!
|
||||
//! Entries within directories are referenced using [`VPath`]s (**v**irtual **path**s).
|
||||
//! A virtual path is composed out of any amount of `/`-separated components.
|
||||
//!
|
||||
//! There are no special directories like `.` and `..` (those are just normal entries, though using
|
||||
//! them is discouraged). [`VPath`]s are always relative to the root of the [`Dir`] you're querying.
|
||||
//!
|
||||
//! A leading or trailing slash is not allowed, because they would have no meaning.
|
||||
//!
|
||||
//! [`VPath`] also has an owned version, [`VPathBuf`].
|
||||
|
||||
use std::{
|
||||
fmt::{self, Debug},
|
||||
ops::{ControlFlow, Deref},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
mod anchored;
|
||||
pub mod asynch;
|
||||
mod cd;
|
||||
mod content_cache;
|
||||
mod content_version_cache;
|
||||
mod edit;
|
||||
mod empty;
|
||||
mod file;
|
||||
mod image_size_cache;
|
||||
mod mem_dir;
|
||||
mod overlay;
|
||||
mod path;
|
||||
mod physical;
|
||||
|
||||
pub use anchored::*;
|
||||
pub use cd::*;
|
||||
pub use content_cache::*;
|
||||
pub use content_version_cache::*;
|
||||
pub use edit::*;
|
||||
pub use empty::*;
|
||||
pub use file::*;
|
||||
pub use image_size_cache::*;
|
||||
pub use mem_dir::*;
|
||||
pub use overlay::*;
|
||||
pub use path::*;
|
||||
pub use physical::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct DirEntry {
|
||||
pub path: VPathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct ImageSize {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
pub trait Dir: Debug {
|
||||
/// List all entries under the provided path.
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry>;
|
||||
|
||||
/// Return the byte content of the entry at the given path.
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>>;
|
||||
|
||||
/// Get a string signifying the current version of the provided path's content.
|
||||
/// If the content changes, the version must also change.
|
||||
///
|
||||
/// Returns None if there is no content or no version string is available.
|
||||
fn content_version(&self, path: &VPath) -> Option<String>;
|
||||
|
||||
/// Returns the size of the image at the given path, or `None` if the entry is not an image
|
||||
/// (or its size cannot be known.)
|
||||
fn image_size(&self, _path: &VPath) -> Option<ImageSize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns a path relative to `config.site` indicating where the file will be available
|
||||
/// once served.
|
||||
///
|
||||
/// May return `None` if the file is not served.
|
||||
fn anchor(&self, _path: &VPath) -> Option<VPathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// If a file can be written persistently, returns an [`EditPath`] representing the file in
|
||||
/// persistent storage.
|
||||
///
|
||||
/// An edit path can then be made into an [`Edit`].
|
||||
fn edit_path(&self, _path: &VPath) -> Option<EditPath> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Dir for &T
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
(**self).dir(path)
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
(**self).content(path)
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
(**self).content_version(path)
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
(**self).image_size(path)
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
(**self).anchor(path)
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
(**self).edit_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DynDir {
|
||||
arc: Arc<dyn Dir + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Dir for DynDir {
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
self.arc.dir(path)
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
self.arc.content(path)
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
self.arc.content_version(path)
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
self.arc.image_size(path)
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
self.arc.anchor(path)
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
self.arc.edit_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DynDir {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(&*self.arc, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for DynDir {
|
||||
type Target = dyn Dir + Send + Sync;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.arc
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToDynDir {
|
||||
fn to_dyn(self) -> DynDir;
|
||||
}
|
||||
|
||||
impl<T> ToDynDir for T
|
||||
where
|
||||
T: Dir + Send + Sync + 'static,
|
||||
{
|
||||
fn to_dyn(self) -> DynDir {
|
||||
DynDir {
|
||||
arc: Arc::new(self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AnchoredAtExt {
|
||||
fn anchored_at(self, at: VPathBuf) -> Anchored<Self>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
impl<T> AnchoredAtExt for T
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn anchored_at(self, at: VPathBuf) -> Anchored<Self> {
|
||||
Anchored::new(self, at)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_dir_rec(dir: &dyn Dir, path: &VPath, f: &mut dyn FnMut(&VPath) -> ControlFlow<(), ()>) {
|
||||
for entry in dir.dir(path) {
|
||||
match f(&entry.path) {
|
||||
ControlFlow::Continue(_) => (),
|
||||
ControlFlow::Break(_) => return,
|
||||
}
|
||||
walk_dir_rec(dir, &entry.path, f);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(site: &str, dir: &dyn Dir, path: &VPath) -> Option<String> {
|
||||
let anchor = dir.anchor(path)?;
|
||||
if let Some(version) = dir.content_version(path) {
|
||||
Some(format!("{}/{anchor}?v={version}", site))
|
||||
} else {
|
||||
Some(format!("{}/{anchor}", site))
|
||||
}
|
||||
}
|
|
@ -1,52 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
|
||||
|
||||
pub struct Anchored<T> {
|
||||
inner: T,
|
||||
at: VPathBuf,
|
||||
}
|
||||
|
||||
impl<T> Anchored<T> {
|
||||
pub fn new(inner: T, at: VPathBuf) -> Self {
|
||||
Self { inner, at }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Dir for Anchored<T>
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
self.inner.dir(path)
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
self.inner.content(path)
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
self.inner.content_version(path)
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
self.inner.image_size(path)
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
Some(self.at.join(path))
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
self.inner.edit_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for Anchored<T>
|
||||
where
|
||||
T: fmt::Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Anchored({:?}, {})", self.inner, self.at)
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
use super::{Dir, DynDir, VPath};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AsyncDir {
|
||||
inner: DynDir,
|
||||
}
|
||||
|
||||
impl AsyncDir {
|
||||
pub fn new(inner: DynDir) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
pub async fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
let this = self.clone();
|
||||
let path = path.to_owned();
|
||||
// NOTE: Performance impact of spawning a blocking task may be a bit high in case
|
||||
// we add caching.
|
||||
// Measure throughput here.
|
||||
tokio::task::spawn_blocking(move || this.inner.content(&path))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
|
||||
|
||||
pub struct Cd<T> {
|
||||
parent: T,
|
||||
path: VPathBuf,
|
||||
}
|
||||
|
||||
impl<T> Cd<T> {
|
||||
pub fn new(parent: T, path: VPathBuf) -> Self {
|
||||
Self { parent, path }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Dir for Cd<T>
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
self.parent
|
||||
.dir(&self.path.join(path))
|
||||
.into_iter()
|
||||
.map(|entry| DirEntry {
|
||||
path: entry
|
||||
.path
|
||||
.strip_prefix(&self.path)
|
||||
.expect("all entries must be anchored within `self.path`")
|
||||
.to_owned(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
self.parent.content_version(&self.path.join(path))
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
self.parent.content(&self.path.join(path))
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
self.parent.image_size(&self.path.join(path))
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
self.parent.anchor(&self.path.join(path))
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
self.parent.edit_path(&self.path.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for Cd<T>
|
||||
where
|
||||
T: fmt::Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}/{:?}", self.parent, self.path)
|
||||
}
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
use std::{
|
||||
fmt::{self, Debug},
|
||||
ops::ControlFlow,
|
||||
};
|
||||
|
||||
use dashmap::DashMap;
|
||||
use log::debug;
|
||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||
|
||||
use super::{walk_dir_rec, Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
|
||||
|
||||
pub struct ContentCache<T> {
|
||||
inner: T,
|
||||
cache: DashMap<VPathBuf, Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl<T> ContentCache<T> {
|
||||
pub fn new(inner: T) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
cache: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ContentCache<T>
|
||||
where
|
||||
T: Dir + Send + Sync,
|
||||
{
|
||||
pub fn warm_up(&self) {
|
||||
debug!("warm_up({self:?})");
|
||||
let mut paths = vec![];
|
||||
walk_dir_rec(&self.inner, VPath::ROOT, &mut |path| {
|
||||
paths.push(path.to_owned());
|
||||
ControlFlow::Continue(())
|
||||
});
|
||||
|
||||
paths.par_iter().for_each(|path| _ = self.content(path));
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Dir for ContentCache<T>
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
self.inner.dir(path)
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
self.cache
|
||||
.entry(path.to_owned())
|
||||
.or_insert_with(|| self.inner.content(path))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
self.inner.content_version(path)
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
self.inner.image_size(path)
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
self.inner.anchor(path)
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
self.inner.edit_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for ContentCache<T>
|
||||
where
|
||||
T: Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ContentCache({:?})", self.inner)
|
||||
}
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
use std::fmt::{self, Debug};
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
|
||||
|
||||
pub struct Blake3ContentVersionCache<T> {
|
||||
inner: T,
|
||||
cache: DashMap<VPathBuf, Option<String>>,
|
||||
}
|
||||
|
||||
impl<T> Blake3ContentVersionCache<T> {
|
||||
pub fn new(inner: T) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
cache: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Dir for Blake3ContentVersionCache<T>
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
self.inner.dir(path)
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
self.inner.content(path)
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
self.cache
|
||||
.entry(path.to_owned())
|
||||
.or_insert_with(|| {
|
||||
self.content(path).map(|content| {
|
||||
let hash = blake3::hash(&content).to_hex();
|
||||
format!("b3-{}", &hash[0..8])
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
self.inner.image_size(path)
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
self.inner.anchor(path)
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
self.inner.edit_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for Blake3ContentVersionCache<T>
|
||||
where
|
||||
T: Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Blake3ContentVersionCache({:?})", self.inner)
|
||||
}
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
use std::{error::Error, fmt, future::Future, path::PathBuf};
|
||||
|
||||
use log::{error, info};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct EditPath {
|
||||
pub(super) path: PathBuf,
|
||||
}
|
||||
|
||||
/// Represents a pending edit operation that can be written to persistent storage later.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Edit {
|
||||
/// An edit that doesn't do anything.
|
||||
NoOp,
|
||||
|
||||
/// Write the given string to a file.
|
||||
Write(EditPath, String),
|
||||
|
||||
/// Execute a sequence of edits in order.
|
||||
Seq(Vec<Edit>),
|
||||
/// Execute the provided edits in parallel.
|
||||
All(Vec<Edit>),
|
||||
|
||||
/// Makes an edit dry.
|
||||
///
|
||||
/// A dry edit only logs what operations would be performed, does not perform the I/O.
|
||||
Dry(Box<Edit>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ApplyFailed;
|
||||
|
||||
impl Edit {
|
||||
#[expect(clippy::manual_async_fn)]
|
||||
pub fn apply(self) -> impl Future<Output = Result<(), ApplyFailed>> + Send {
|
||||
async {
|
||||
match self {
|
||||
Edit::NoOp => (),
|
||||
Edit::Write(edit_path, content) => {
|
||||
tokio::fs::write(&edit_path.path, &content)
|
||||
.await
|
||||
.inspect_err(|err| error!("write to {edit_path:?} failed: {err:?}"))
|
||||
.map_err(|_| ApplyFailed)?;
|
||||
}
|
||||
Edit::Seq(vec) => {
|
||||
for edit in vec {
|
||||
Box::pin(edit.apply()).await?;
|
||||
}
|
||||
}
|
||||
Edit::All(vec) => {
|
||||
let mut set = JoinSet::new();
|
||||
for edit in vec {
|
||||
set.spawn(edit.apply());
|
||||
}
|
||||
while let Some(result) = set.try_join_next() {
|
||||
result.map_err(|_| ApplyFailed)??;
|
||||
}
|
||||
}
|
||||
Edit::Dry(edit) => edit.dry(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dry(&self) {
|
||||
match self {
|
||||
Edit::NoOp => (),
|
||||
Edit::Write(edit_path, content) => {
|
||||
info!("{edit_path:?}: would write {:?} bytes", content.len());
|
||||
}
|
||||
Edit::Seq(edits) => edits.iter().for_each(Self::dry),
|
||||
Edit::All(edits) => edits.iter().for_each(Self::dry),
|
||||
Edit::Dry(edit) => edit.dry(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ApplyFailed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("failed to apply some edits")
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ApplyFailed {}
|
||||
|
||||
impl fmt::Debug for EditPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(&self.path, f)
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
use super::{Dir, DirEntry, VPath};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EmptyEntry;
|
||||
|
||||
impl Dir for EmptyEntry {
|
||||
fn dir(&self, _path: &VPath) -> Vec<DirEntry> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn content_version(&self, _path: &VPath) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn content(&self, _path: &VPath) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
use super::{DirEntry, Dir, VPath};
|
||||
|
||||
pub struct BufferedFile {
|
||||
pub content: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BufferedFile {
|
||||
pub fn new(content: Vec<u8>) -> Self {
|
||||
Self { content }
|
||||
}
|
||||
}
|
||||
|
||||
impl Dir for BufferedFile {
|
||||
fn dir(&self, _path: &VPath) -> Vec<DirEntry> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn content_version(&self, _path: &VPath) -> Option<String> {
|
||||
// TODO: StaticFile should _probably_ calculate a content_version.
|
||||
None
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
if path == VPath::ROOT {
|
||||
Some(self.content.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for BufferedFile {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "BufferedFile")
|
||||
}
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
use std::{fmt, io::Cursor};
|
||||
|
||||
use anyhow::Context;
|
||||
use dashmap::DashMap;
|
||||
use log::{debug, warn};
|
||||
|
||||
use crate::config;
|
||||
|
||||
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
|
||||
|
||||
pub struct ImageSizeCache<T> {
|
||||
inner: T,
|
||||
cache: DashMap<VPathBuf, Option<ImageSize>>,
|
||||
}
|
||||
|
||||
impl<T> ImageSizeCache<T> {
|
||||
pub fn new(inner: T) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
cache: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ImageSizeCache<T>
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn compute_image_size(&self, path: &VPath) -> anyhow::Result<Option<ImageSize>> {
|
||||
if path.extension().is_some_and(config::is_image_file) {
|
||||
if let Some(content) = self.content(path) {
|
||||
let reader = image::ImageReader::new(Cursor::new(content))
|
||||
.with_guessed_format()
|
||||
.context("cannot guess image format")?;
|
||||
let (width, height) = reader.into_dimensions()?;
|
||||
debug!("image_size({path}) = ({width}, {height})");
|
||||
return Ok(Some(ImageSize { width, height }));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Dir for ImageSizeCache<T>
|
||||
where
|
||||
T: Dir,
|
||||
{
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
self.inner.dir(path)
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
self.inner.content(path)
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
self.inner.content_version(path)
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
self.cache
|
||||
.entry(path.to_owned())
|
||||
.or_insert_with(|| {
|
||||
self.compute_image_size(path)
|
||||
.inspect_err(|err| warn!("compute_image_size({path}) failed: {err:?}"))
|
||||
.ok()
|
||||
.flatten()
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
self.inner.anchor(path)
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
self.inner.edit_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for ImageSizeCache<T>
|
||||
where
|
||||
T: fmt::Debug,
|
||||
{
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ImageSizeCache({:?})", self.inner)
|
||||
}
|
||||
}
|
|
@ -1,151 +0,0 @@
|
|||
use std::{collections::HashMap, fmt};
|
||||
|
||||
use super::{Dir, DirEntry, DynDir, EditPath, ImageSize, VPath, VPathBuf};
|
||||
|
||||
pub struct MemDir {
|
||||
mount_points: HashMap<String, DynDir>,
|
||||
}
|
||||
|
||||
enum Resolved<'fs, 'path> {
|
||||
Root,
|
||||
MountPoint {
|
||||
fs: &'fs dyn Dir,
|
||||
fs_path: &'path VPath,
|
||||
subpath: &'path VPath,
|
||||
},
|
||||
None,
|
||||
}
|
||||
|
||||
impl MemDir {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mount_points: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, path: &VPath, dir: DynDir) {
|
||||
assert_eq!(
|
||||
path.depth(), 0,
|
||||
"path must be situated at root. MountPoints does not support nested paths, but you can nest MountPoints within other MountPoints"
|
||||
);
|
||||
|
||||
assert!(
|
||||
self.mount_points
|
||||
.insert(path.as_str().to_owned(), dir)
|
||||
.is_none(),
|
||||
"duplicate mount point at {path:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn resolve<'fs, 'path>(&'fs self, path: &'path VPath) -> Resolved<'fs, 'path> {
|
||||
if path == VPath::ROOT {
|
||||
return Resolved::Root;
|
||||
} else {
|
||||
let mount_point_name = path.as_str().split(VPath::SEPARATOR).next().unwrap();
|
||||
if let Some(mount_point) = self.mount_points.get(mount_point_name) {
|
||||
return Resolved::MountPoint {
|
||||
fs: &**mount_point,
|
||||
fs_path: VPath::new(mount_point_name),
|
||||
subpath: path
|
||||
.strip_prefix(VPath::new(mount_point_name))
|
||||
.expect("path should have `mount_point_name` as its prefix"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Resolved::None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MemDir {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Dir for MemDir {
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
match self.resolve(path) {
|
||||
Resolved::Root => self
|
||||
.mount_points
|
||||
.keys()
|
||||
.map(|name| DirEntry {
|
||||
path: VPathBuf::new(name),
|
||||
})
|
||||
.collect(),
|
||||
Resolved::MountPoint {
|
||||
fs,
|
||||
fs_path,
|
||||
subpath,
|
||||
} => fs
|
||||
.dir(subpath)
|
||||
.into_iter()
|
||||
.map(|entry| DirEntry {
|
||||
path: fs_path.join(&entry.path),
|
||||
})
|
||||
.collect(),
|
||||
Resolved::None => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
match self.resolve(path) {
|
||||
Resolved::MountPoint {
|
||||
fs,
|
||||
fs_path: _,
|
||||
subpath,
|
||||
} => fs.content_version(subpath),
|
||||
Resolved::Root | Resolved::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
match self.resolve(path) {
|
||||
Resolved::MountPoint {
|
||||
fs,
|
||||
fs_path: _,
|
||||
subpath,
|
||||
} => fs.content(subpath),
|
||||
Resolved::Root | Resolved::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
match self.resolve(path) {
|
||||
Resolved::MountPoint {
|
||||
fs,
|
||||
fs_path: _,
|
||||
subpath,
|
||||
} => fs.image_size(subpath),
|
||||
Resolved::Root | Resolved::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
match self.resolve(path) {
|
||||
Resolved::MountPoint {
|
||||
fs,
|
||||
fs_path: _,
|
||||
subpath,
|
||||
} => fs.anchor(subpath),
|
||||
Resolved::Root | Resolved::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
match self.resolve(path) {
|
||||
Resolved::MountPoint {
|
||||
fs,
|
||||
fs_path: _,
|
||||
subpath,
|
||||
} => fs.edit_path(subpath),
|
||||
Resolved::Root | Resolved::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MemDir {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("MountPoints")
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
use super::{Dir, DirEntry, DynDir, EditPath, ImageSize, VPath, VPathBuf};
|
||||
|
||||
pub struct Overlay {
|
||||
base: DynDir,
|
||||
overlay: DynDir,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
pub fn new(base: DynDir, overlay: DynDir) -> Self {
|
||||
Self { base, overlay }
|
||||
}
|
||||
}
|
||||
|
||||
impl Dir for Overlay {
|
||||
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
|
||||
let mut dir = self.base.dir(path);
|
||||
dir.append(&mut self.overlay.dir(path));
|
||||
dir.sort();
|
||||
dir.dedup();
|
||||
dir
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
self.overlay
|
||||
.content(path)
|
||||
.or_else(|| self.base.content(path))
|
||||
}
|
||||
|
||||
fn content_version(&self, path: &VPath) -> Option<String> {
|
||||
self.overlay
|
||||
.content_version(path)
|
||||
.or_else(|| self.base.content_version(path))
|
||||
}
|
||||
|
||||
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
|
||||
self.overlay
|
||||
.image_size(path)
|
||||
.or_else(|| self.base.image_size(path))
|
||||
}
|
||||
|
||||
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
|
||||
self.overlay.anchor(path).or_else(|| self.base.anchor(path))
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
self.overlay
|
||||
.edit_path(path)
|
||||
.or_else(|| self.base.edit_path(path))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Overlay {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Overlay({:?}, {:?})", self.base, self.overlay)
|
||||
}
|
||||
}
|
|
@ -1,316 +0,0 @@
|
|||
use std::{borrow::Borrow, error::Error, fmt, ops::Deref, str::FromStr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct VPath {
|
||||
path: str,
|
||||
}
|
||||
|
||||
impl VPath {
|
||||
pub const SEPARATOR_BYTE: u8 = b'/';
|
||||
pub const SEPARATOR: char = Self::SEPARATOR_BYTE as char;
|
||||
pub const ROOT: &Self = unsafe { Self::new_unchecked("") };
|
||||
|
||||
pub const fn try_new(s: &str) -> Result<&Self, InvalidPathError> {
|
||||
if s.is_empty() {
|
||||
return Ok(Self::ROOT);
|
||||
}
|
||||
|
||||
let b = s.as_bytes();
|
||||
if b[b.len() - 1] == Self::SEPARATOR_BYTE {
|
||||
return Err(InvalidPathError::TrailingSlash);
|
||||
}
|
||||
if b[0] == Self::SEPARATOR_BYTE {
|
||||
return Err(InvalidPathError::LeadingSlash);
|
||||
}
|
||||
|
||||
Ok(unsafe { Self::new_unchecked(s) })
|
||||
}
|
||||
|
||||
pub fn new(s: &str) -> &Self {
|
||||
Self::try_new(s).expect("invalid path")
|
||||
}
|
||||
|
||||
/// `const` version of [`new`][Self::new]. This has worse error messages, so prefer `new` whenever possible.
|
||||
pub const fn new_const(s: &str) -> &Self {
|
||||
match Self::try_new(s) {
|
||||
Ok(p) => p,
|
||||
Err(_) => panic!("invalid path"),
|
||||
}
|
||||
}
|
||||
|
||||
const unsafe fn new_unchecked(s: &str) -> &Self {
|
||||
std::mem::transmute::<_, &Self>(s)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.path.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.is_empty()
|
||||
}
|
||||
|
||||
pub fn join(&self, sub: &VPath) -> VPathBuf {
|
||||
let mut buf = self.to_owned();
|
||||
buf.push(sub);
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn parent(&self) -> Option<&VPath> {
|
||||
if self.is_root() {
|
||||
None
|
||||
} else if self.depth() == 0 {
|
||||
Some(VPath::ROOT)
|
||||
} else {
|
||||
let (left, _right) = self
|
||||
.path
|
||||
.split_once(Self::SEPARATOR)
|
||||
.expect("path with depth > 0 must have separators");
|
||||
// SAFETY: We're splitting on a `/`, so there cannot be a trailing `/` in `left`.
|
||||
Some(unsafe { VPath::new_unchecked(left) })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strip_prefix(&self, prefix: &VPath) -> Option<&Self> {
|
||||
if self == prefix {
|
||||
Some(VPath::ROOT)
|
||||
} else {
|
||||
self.path
|
||||
.strip_prefix(&prefix.path)
|
||||
.and_then(|p| p.strip_prefix(Self::SEPARATOR))
|
||||
// SAFETY: If `self` starts with `prefix`, `p` will end up not being prefixed by `self`
|
||||
// nor a leading slash.
|
||||
.map(|p| unsafe { VPath::new_unchecked(p) })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn depth(&self) -> usize {
|
||||
self.path.chars().filter(|&c| c == Self::SEPARATOR).count()
|
||||
}
|
||||
|
||||
pub fn segments(&self) -> impl Iterator<Item = &Self> {
|
||||
if self.is_root() {
|
||||
None.into_iter().flatten()
|
||||
} else {
|
||||
Some(self.as_str().split(Self::SEPARATOR).map(|s| unsafe {
|
||||
// SAFETY: Since we're splitting on the separator, the path cannot start or end with it.
|
||||
Self::new_unchecked(s)
|
||||
}))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rsegments(&self) -> impl Iterator<Item = &Self> {
|
||||
self.as_str().rsplit(Self::SEPARATOR).map(|s| unsafe {
|
||||
// SAFETY: Since we're splitting on the separator, the path cannot start or end with it.
|
||||
Self::new_unchecked(s)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn file_name(&self) -> Option<&str> {
|
||||
self.rsegments().next().map(Self::as_str)
|
||||
}
|
||||
|
||||
pub fn extension(&self) -> Option<&str> {
|
||||
let file_name = self.file_name()?;
|
||||
let (left, right) = file_name.rsplit_once('.')?;
|
||||
if left.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(right)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_extension(&self, extension: &str) -> VPathBuf {
|
||||
let mut buf = self.to_owned();
|
||||
buf.set_extension(extension);
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn file_stem(&self) -> Option<&str> {
|
||||
let file_name = self.file_name()?;
|
||||
if let Some(extension) = self.extension() {
|
||||
Some(&file_name[..file_name.len() - extension.len() - 1])
|
||||
} else {
|
||||
Some(file_name)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl ToOwned for VPath {
|
||||
type Owned = VPathBuf;
|
||||
|
||||
fn to_owned(&self) -> Self::Owned {
|
||||
VPathBuf::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for VPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InvalidPathError {
|
||||
TrailingSlash,
|
||||
LeadingSlash,
|
||||
}
|
||||
|
||||
impl fmt::Display for InvalidPathError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
InvalidPathError::TrailingSlash => {
|
||||
f.write_str("paths must not end with a trailing `/`")
|
||||
}
|
||||
InvalidPathError::LeadingSlash => {
|
||||
f.write_str("paths are always absolute and must not start with `/`")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for InvalidPathError {}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct VPathBuf {
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl VPathBuf {
|
||||
pub fn new(path: impl Into<String>) -> Self {
|
||||
Self::try_new(path).expect("invalid path")
|
||||
}
|
||||
|
||||
pub fn try_new(path: impl Into<String>) -> Result<Self, InvalidPathError> {
|
||||
let path = path.into();
|
||||
match VPath::try_new(&path) {
|
||||
Ok(_) => Ok(Self { path }),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn new_unchecked(path: String) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, sub: &VPath) {
|
||||
if !sub.is_empty() {
|
||||
if !self.is_empty() {
|
||||
self.path.push('/');
|
||||
}
|
||||
self.path.push_str(&sub.path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_extension(&mut self, new_extension: &str) {
|
||||
if let Some(existing) = self.extension() {
|
||||
let mut chop_len = existing.len();
|
||||
if new_extension.is_empty() {
|
||||
chop_len += 1; // also chop off the `.`
|
||||
}
|
||||
|
||||
let range = self.path.len() - chop_len..;
|
||||
self.path.replace_range(range, new_extension);
|
||||
} else {
|
||||
self.path.push('.');
|
||||
self.path.push_str(new_extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VPathBuf {
|
||||
fn default() -> Self {
|
||||
VPath::ROOT.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for VPathBuf {
|
||||
type Target = VPath;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
unsafe { VPath::new_unchecked(&self.path) }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for VPathBuf {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VPathBuf {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&VPath> for VPathBuf {
|
||||
fn from(value: &VPath) -> Self {
|
||||
unsafe { Self::new_unchecked(value.path.to_owned()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Borrow<VPath> for VPathBuf {
|
||||
fn borrow(&self) -> &VPath {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for VPathBuf {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct Visitor;
|
||||
|
||||
impl de::Visitor<'_> for Visitor {
|
||||
type Value = VPathBuf;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("virtual path")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
VPathBuf::try_new(v).map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_str(Visitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for VPathBuf {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for VPathBuf {
|
||||
type Err = InvalidPathError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::try_new(s)
|
||||
}
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use log::error;
|
||||
|
||||
use super::{Dir, DirEntry, EditPath, VPath, VPathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PhysicalDir {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl PhysicalDir {
|
||||
pub fn new(root: PathBuf) -> Self {
|
||||
Self { root }
|
||||
}
|
||||
}
|
||||
|
||||
impl Dir for PhysicalDir {
|
||||
fn dir(&self, vpath: &VPath) -> Vec<DirEntry> {
|
||||
let physical = self.root.join(physical_path(vpath));
|
||||
if !physical.is_dir() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
match std::fs::read_dir(physical) {
|
||||
Ok(read_dir) => read_dir
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.inspect_err(|err| {
|
||||
error!(
|
||||
"{self:?} error while reading entries: {err:?}",
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
.and_then(|entry| {
|
||||
let path = entry.path();
|
||||
let path_str = match path.strip_prefix(&self.root).unwrap_or(&path).to_str() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
error!("{self:?} entry {path:?} has invalid UTF-8 (while reading vpath {vpath:?})");
|
||||
return None;
|
||||
},
|
||||
};
|
||||
let vpath_buf = VPathBuf::try_new(path_str.replace('\\', "/"))
|
||||
.inspect_err(|err| {
|
||||
error!("{self:?} error with vpath for {path_str:?}: {err:?}");
|
||||
})
|
||||
.ok()?;
|
||||
Some(DirEntry { path: vpath_buf })
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
error!(
|
||||
"{self:?} cannot read vpath {vpath:?}: {err:?}",
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn content_version(&self, _path: &VPath) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
|
||||
std::fs::read(self.root.join(physical_path(path)))
|
||||
.inspect_err(|err| error!("{self:?} cannot read file at vpath {path:?}: {err:?}",))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
|
||||
Some(EditPath {
|
||||
path: self.root.join(physical_path(path)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn physical_path(path: &VPath) -> &Path {
|
||||
Path::new(path.as_str())
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
mod vfs;
|
|
@ -1,5 +0,0 @@
|
|||
mod cd;
|
||||
mod empty;
|
||||
mod file;
|
||||
mod mount_points;
|
||||
mod physical;
|
|
@ -1,83 +0,0 @@
|
|||
use treehouse::vfs::{BufferedFile, Cd, Dir, DirEntry, MemDir, ToDynDir, VPath, VPathBuf};
|
||||
|
||||
const HEWWO: &[u8] = b"hewwo :3";
|
||||
const FWOOFEE: &[u8] = b"fwoofee -w-";
|
||||
const BOOP: &[u8] = b"boop >w<";
|
||||
|
||||
fn vfs() -> MemDir {
|
||||
let file1 = BufferedFile::new(HEWWO.to_vec());
|
||||
let file2 = BufferedFile::new(FWOOFEE.to_vec());
|
||||
let file3 = BufferedFile::new(BOOP.to_vec());
|
||||
|
||||
let mut innermost = MemDir::new();
|
||||
innermost.add(VPath::new("file3.txt"), file3.to_dyn());
|
||||
|
||||
let mut inner = MemDir::new();
|
||||
inner.add(VPath::new("file1.txt"), file1.to_dyn());
|
||||
inner.add(VPath::new("file2.txt"), file2.to_dyn());
|
||||
inner.add(VPath::new("innermost"), innermost.to_dyn());
|
||||
|
||||
let mut vfs = MemDir::new();
|
||||
vfs.add(VPath::new("inner"), inner.to_dyn());
|
||||
vfs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir1() {
|
||||
let outer = vfs();
|
||||
let inner = Cd::new(outer, VPathBuf::new("inner"));
|
||||
|
||||
let mut dir = inner.dir(VPath::ROOT);
|
||||
dir.sort();
|
||||
assert_eq!(
|
||||
dir,
|
||||
vec![
|
||||
DirEntry {
|
||||
path: VPathBuf::new("file1.txt"),
|
||||
},
|
||||
DirEntry {
|
||||
path: VPathBuf::new("file2.txt"),
|
||||
},
|
||||
DirEntry {
|
||||
path: VPathBuf::new("innermost"),
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir2() {
|
||||
let outer = vfs();
|
||||
let innermost = Cd::new(&outer, VPathBuf::new("inner/innermost"));
|
||||
|
||||
let mut dir = innermost.dir(VPath::ROOT);
|
||||
dir.sort();
|
||||
assert_eq!(
|
||||
dir,
|
||||
vec![DirEntry {
|
||||
path: VPathBuf::new("file3.txt"),
|
||||
},]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_version() {
|
||||
let outer = vfs();
|
||||
let inner = Cd::new(&outer, VPathBuf::new("inner"));
|
||||
|
||||
assert_eq!(
|
||||
inner.content_version(VPath::new("test1.txt")),
|
||||
outer.content_version(VPath::new("inner/test1.txt"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content() {
|
||||
let outer = vfs();
|
||||
let inner = Cd::new(&outer, VPathBuf::new("inner"));
|
||||
|
||||
assert_eq!(
|
||||
inner.content(VPath::new("test1.txt")),
|
||||
outer.content(VPath::new("inner/test1.txt"))
|
||||
);
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
use treehouse::vfs::{Dir, EmptyEntry, VPath};
|
||||
|
||||
#[test]
|
||||
fn dir() {
|
||||
assert!(EmptyEntry.dir(VPath::ROOT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_version() {
|
||||
assert!(EmptyEntry.content_version(VPath::ROOT).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content() {
|
||||
assert!(EmptyEntry.content(VPath::ROOT).is_none());
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
use treehouse::vfs::{BufferedFile, Dir, VPath};
|
||||
|
||||
fn vfs() -> BufferedFile {
|
||||
BufferedFile::new(b"hewwo :3".to_vec())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir() {
|
||||
let vfs = vfs();
|
||||
assert!(vfs.dir(VPath::ROOT).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_version() {
|
||||
let vfs = vfs();
|
||||
assert!(
|
||||
vfs.content_version(VPath::ROOT).is_none(),
|
||||
"content_version is not implemented for BufferedFile for now"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content() {
|
||||
let vfs = vfs();
|
||||
assert_eq!(
|
||||
vfs.content(VPath::ROOT).as_deref(),
|
||||
Some(b"hewwo :3".as_slice()),
|
||||
);
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
use treehouse::vfs::{BufferedFile, Dir, DirEntry, MemDir, ToDynDir, VPath, VPathBuf};
|
||||
|
||||
const HEWWO: &[u8] = b"hewwo :3";
|
||||
const FWOOFEE: &[u8] = b"fwoofee -w-";
|
||||
const BOOP: &[u8] = b"boop >w<";
|
||||
|
||||
fn vfs() -> MemDir {
|
||||
let file1 = BufferedFile::new(HEWWO.to_vec());
|
||||
let file2 = BufferedFile::new(FWOOFEE.to_vec());
|
||||
let file3 = BufferedFile::new(BOOP.to_vec());
|
||||
|
||||
let mut inner = MemDir::new();
|
||||
inner.add(VPath::new("file3.txt"), file3.to_dyn());
|
||||
|
||||
let mut vfs = MemDir::new();
|
||||
vfs.add(VPath::new("file1.txt"), file1.to_dyn());
|
||||
vfs.add(VPath::new("file2.txt"), file2.to_dyn());
|
||||
vfs.add(VPath::new("inner"), inner.to_dyn());
|
||||
vfs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir() {
|
||||
let vfs = vfs();
|
||||
|
||||
let mut dir = vfs.dir(VPath::new(""));
|
||||
dir.sort();
|
||||
assert_eq!(
|
||||
dir,
|
||||
vec![
|
||||
DirEntry {
|
||||
path: VPathBuf::new("file1.txt"),
|
||||
},
|
||||
DirEntry {
|
||||
path: VPathBuf::new("file2.txt"),
|
||||
},
|
||||
DirEntry {
|
||||
path: VPathBuf::new("inner"),
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
assert!(vfs.dir(VPath::new("file1.txt")).is_empty());
|
||||
assert!(vfs.dir(VPath::new("file2.txt")).is_empty());
|
||||
assert_eq!(
|
||||
vfs.dir(VPath::new("inner")),
|
||||
vec![DirEntry {
|
||||
path: VPathBuf::new("inner/file3.txt")
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_version() {
|
||||
let vfs = vfs();
|
||||
|
||||
let file1 = BufferedFile::new(HEWWO.to_vec());
|
||||
let file2 = BufferedFile::new(FWOOFEE.to_vec());
|
||||
let file3 = BufferedFile::new(BOOP.to_vec());
|
||||
|
||||
assert_eq!(
|
||||
vfs.content_version(VPath::new("file1.txt")),
|
||||
file1.content_version(VPath::ROOT)
|
||||
);
|
||||
assert_eq!(
|
||||
vfs.content_version(VPath::new("file2.txt")),
|
||||
file2.content_version(VPath::ROOT)
|
||||
);
|
||||
assert_eq!(
|
||||
vfs.content_version(VPath::new("inner/file3.txt")),
|
||||
file3.content_version(VPath::ROOT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content() {
|
||||
let vfs = vfs();
|
||||
|
||||
assert_eq!(vfs.content(VPath::new("file1.txt")).as_deref(), Some(HEWWO));
|
||||
assert_eq!(
|
||||
vfs.content(VPath::new("file2.txt")).as_deref(),
|
||||
Some(FWOOFEE)
|
||||
);
|
||||
assert_eq!(
|
||||
vfs.content(VPath::new("inner/file3.txt")).as_deref(),
|
||||
Some(BOOP)
|
||||
);
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
use std::path::Path;
|
||||
|
||||
use treehouse::vfs::{DirEntry, PhysicalDir, Dir, VPath, VPathBuf};
|
||||
|
||||
fn vfs() -> PhysicalDir {
|
||||
let root = Path::new("tests/it/vfs_physical").to_path_buf();
|
||||
PhysicalDir::new(root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir() {
|
||||
let vfs = vfs();
|
||||
let dir = vfs.dir(VPath::ROOT);
|
||||
assert_eq!(
|
||||
&dir[..],
|
||||
&[DirEntry {
|
||||
path: VPathBuf::new("test.txt"),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_version() {
|
||||
let vfs = vfs();
|
||||
let content_version = vfs.content_version(VPath::new("test.txt"));
|
||||
assert_eq!(
|
||||
content_version, None,
|
||||
"content_version remains unimplemented for now"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content() {
|
||||
let vfs = vfs();
|
||||
let content = vfs.content(VPath::new("test.txt"));
|
||||
assert_eq!(content.as_deref(), Some(b"hewwo :3\n".as_slice()));
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
hewwo :3
|
|
@ -117,12 +117,12 @@ body {
|
|||
Other assets are referenced rarely enough that caching probably isn't gonna make too much of
|
||||
an impact.
|
||||
It's unlikely I'll ever update the font anyways, so eh, whatever. */
|
||||
src: url("../font/Recursive_VF_1.085.woff2?v=b3-445487d5");
|
||||
src: url("../font/Recursive_VF_1.085.woff2?cache=b3-445487d5");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "RecVarMono";
|
||||
src: url("../font/Recursive_VF_1.085.woff2?v=b3-445487d5");
|
||||
src: url("../font/Recursive_VF_1.085.woff2?cache=b3-445487d5");
|
||||
font-variation-settings: "MONO" 1;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
// NOTE: The server never fulfills this request, it stalls forever.
|
||||
// Once the connection is closed, we try to connect with the server until we establish a successful
|
||||
// connection. Then we reload the page.
|
||||
await fetch("/dev/live-reload/stall").catch(async () => {
|
||||
while (true) {
|
||||
try {
|
||||
let response = await fetch("/dev/live-reload/back-up");
|
||||
if (response.status == 200) {
|
||||
window.location.reload();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
});
|
|
@ -104,7 +104,9 @@ class LinkedBranch extends Branch {
|
|||
|
||||
async loadTreePromise(_initiator) {
|
||||
try {
|
||||
let response = await fetch(`${TREEHOUSE_SITE}/${this.linkedTree}`);
|
||||
let response = await fetch(
|
||||
`${TREEHOUSE_SITE}/${this.linkedTree}.html`
|
||||
);
|
||||
if (response.status == 404) {
|
||||
throw `Hmm, seems like the tree "${this.linkedTree}" does not exist.`;
|
||||
}
|
||||
|
@ -125,9 +127,7 @@ class LinkedBranch extends Branch {
|
|||
// No need to await for the import because we don't use the resulting module.
|
||||
// Just fire and forger 💀
|
||||
// and let them run in parallel.
|
||||
let url = URL.createObjectURL(
|
||||
new Blob([script.textContent], { type: "text/javascript" }),
|
||||
);
|
||||
let url = URL.createObjectURL(new Blob([script.textContent], { type: "text/javascript" }))
|
||||
import(url);
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -257,7 +257,10 @@ async function expandLinkedBranch() {
|
|||
let currentlyHighlightedBranch = getCurrentlyHighlightedBranch();
|
||||
if (currentlyHighlightedBranch.length > 0) {
|
||||
let linkedBranch = document.getElementById(currentlyHighlightedBranch);
|
||||
if (linkedBranch.children.length > 0 && linkedBranch.children[0].tagName == "DETAILS") {
|
||||
if (
|
||||
linkedBranch.children.length > 0 &&
|
||||
linkedBranch.children[0].tagName == "DETAILS"
|
||||
) {
|
||||
expandDetailsRecursively(linkedBranch.children[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,17 +11,10 @@
|
|||
<link rel="stylesheet" href="{{ asset 'css/icons.css' }}">
|
||||
<link rel="stylesheet" href="{{ asset 'css/tree.css' }}">
|
||||
|
||||
{{!--
|
||||
Import maps currently don't support the src="" attribute. Unless we come up with something
|
||||
clever to do while browser vendors figure that out, we'll just have to do a cache-busting string substitution.
|
||||
--}}
|
||||
<script type="importmap">{{{ import_map }}}</script>
|
||||
|
||||
{{#if dev}}
|
||||
<script type="module">
|
||||
import "treehouse/live-reload.js";
|
||||
</script>
|
||||
{{/if}}
|
||||
{{!-- Import maps currently don't support the src="" attribute. Unless we come up with something
|
||||
clever to do while browser vendors figure that out, we'll just have to do a cache-busting include_static. --}}
|
||||
{{!-- <script type="importmap" src="{{ asset 'generated/import-map.json' }}"></script> --}}
|
||||
<script type="importmap">{{{ include_static 'generated/import-map.json' }}}</script>
|
||||
|
||||
<script>
|
||||
const TREEHOUSE_SITE = `{{ config.site }}`;
|
||||
|
|
|
@ -3,8 +3,6 @@
|
|||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<title>treehouse iframe sandbox</title>
|
||||
|
||||
<link rel="stylesheet" href="{{ asset 'css/base.css' }}">
|
||||
|
@ -23,7 +21,7 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<script type="importmap">{{{ import_map }}}</script>
|
||||
<script type="importmap">{{{ include_static 'generated/import-map.json' }}}</script>
|
||||
|
||||
<script type="module">
|
||||
import { evaluate, domConsole, jsConsole } from "treehouse/components/literate-programming/eval.js";
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
# This variable can also be set using the TREEHOUSE_SITE environment variable.
|
||||
site = ""
|
||||
|
||||
# TODO djot: Remove once transition is over.
|
||||
markup = "Djot"
|
||||
|
||||
# This is used to generate a link in the footer that links to the page's source commit.
|
||||
# The final URL is `{commit_base_url}/{commit}/content/{tree_path}.tree`.
|
||||
commit_base_url = "https://src.liquidev.net/liquidex/treehouse/src/commit"
|
||||
|
@ -57,7 +60,7 @@ description = "a place on the Internet I like to call home"
|
|||
|
||||
[build.javascript]
|
||||
import_roots = [
|
||||
{ name = "treehouse", path = "" },
|
||||
{ name = "tairu", path = "components/tairu" },
|
||||
{ name = "haku", path = "components/haku" },
|
||||
{ name = "treehouse", path = "static/js" },
|
||||
{ name = "tairu", path = "static/js/components/tairu" },
|
||||
{ name = "haku", path = "static/js/components/haku" },
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue