Compare commits

..

9 commits

Author SHA1 Message Date
liquidex 5193fc2be0 pre-warm content cache
in parallel!
2024-11-23 21:43:58 +01:00
liquidex 42eaa326ab add content cache to generated pages
just so that we don't waste work regenerating them over and over again -w-
2024-11-23 21:43:58 +01:00
liquidex 9022fb4ce9 add image size metadata to the filesystem 2024-11-23 21:43:58 +01:00
liquidex 5ce9cfc022 add live reloading to the client 2024-11-23 21:43:58 +01:00
liquidex 7169e65244 implement a blake3 content version layer for cache busting
works pretty much the same as our previous `?cache` parameter, but is implemented in a less ad-hoc way
2024-11-23 21:43:58 +01:00
liquidex 32f25ce863 implement generation of simple templates (/sandbox) 2024-11-23 21:43:58 +01:00
liquidex 377fbe4dab introduce the virtual filesystem everywhere
this unfortunately means I had to cut some features (bye bye commit history! for now)
stuff's not quite 100% working just yet (like branch links, which were and are still broken)
we also don't have content_version impls just yet
2024-11-23 21:43:58 +01:00
liquidex db0329077e refactors: replacing config derived data with vfs, removing markdown 2024-11-23 21:43:58 +01:00
liquidex 1e1b8df457 refactor: introduce virtual file system as a central router for source and target data 2024-11-23 21:43:58 +01:00
58 changed files with 3217 additions and 2353 deletions

457
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@ -26,6 +26,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aligned-vec"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
[[package]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@ -96,6 +102,23 @@ version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" 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]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.7" version = "0.3.7"
@ -104,9 +127,9 @@ checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.4" version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
@ -125,6 +148,29 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 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]] [[package]]
name = "axum" name = "axum"
version = "0.7.5" version = "0.7.5"
@ -219,6 +265,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1"
[[package]]
name = "bitstream-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
[[package]] [[package]]
name = "blake3" name = "blake3"
version = "1.5.3" version = "1.5.3"
@ -241,6 +293,12 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "built"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c360505aed52b7ec96a3636c3f039d99103c37d1d9b4f7a8c743d3ea9ffcd03b"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.16.0" version = "3.16.0"
@ -254,10 +312,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5" checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5"
[[package]] [[package]]
name = "byteorder" name = "byteorder-lite"
version = "1.5.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
@ -276,6 +334,16 @@ dependencies = [
"shlex", "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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
@ -438,6 +506,20 @@ dependencies = [
"typenum", "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]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -781,22 +863,43 @@ dependencies = [
[[package]] [[package]]
name = "image" name = "image"
version = "0.24.9" version = "0.25.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder", "byteorder-lite",
"color_quant", "color_quant",
"exr", "exr",
"gif", "gif",
"jpeg-decoder", "image-webp",
"num-traits", "num-traits",
"png", "png",
"qoi", "qoi",
"ravif",
"rayon",
"rgb",
"tiff", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.2.6" version = "2.2.6"
@ -808,6 +911,17 @@ dependencies = [
"serde", "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]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.12" version = "0.4.12"
@ -825,6 +939,15 @@ version = "1.70.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800"
[[package]]
name = "itertools"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.11" version = "1.0.11"
@ -851,9 +974,6 @@ name = "jpeg-decoder"
version = "0.3.1" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
dependencies = [
"rayon",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
@ -876,6 +996,16 @@ version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" 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]] [[package]]
name = "libgit2-sys" name = "libgit2-sys"
version = "0.17.0+1.8.1" version = "0.17.0+1.8.1"
@ -916,12 +1046,31 @@ version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.7.3" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.2" version = "2.7.2"
@ -934,6 +1083,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.7.3" version = "0.7.3"
@ -955,6 +1110,69 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -1012,6 +1230,12 @@ dependencies = [
"windows-targets 0.52.5", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.1" version = "2.3.1"
@ -1130,14 +1354,22 @@ dependencies = [
] ]
[[package]] [[package]]
name = "pulldown-cmark" name = "profiling"
version = "0.9.6" version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d"
dependencies = [ dependencies = [
"bitflags 2.5.0", "profiling-procmacros",
"memchr", ]
"unicase",
[[package]]
name = "profiling-procmacros"
version = "1.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30"
dependencies = [
"quote",
"syn",
] ]
[[package]] [[package]]
@ -1149,6 +1381,12 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.36" version = "1.0.36"
@ -1188,6 +1426,56 @@ dependencies = [
"getrandom", "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]] [[package]]
name = "rayon" name = "rayon"
version = "1.10.0" version = "1.10.0"
@ -1246,6 +1534,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
[[package]]
name = "rgb"
version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -1373,6 +1667,15 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" 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]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.13.2" version = "1.13.2"
@ -1427,6 +1730,25 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" 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]] [[package]]
name = "termcolor" name = "termcolor"
version = "1.4.1" version = "1.4.1"
@ -1512,6 +1834,18 @@ dependencies = [
"syn", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.6" version = "0.6.6"
@ -1531,7 +1865,20 @@ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
"winnow", "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",
] ]
[[package]] [[package]]
@ -1556,20 +1903,6 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" 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]] [[package]]
name = "tower-service" name = "tower-service"
version = "0.3.2" version = "0.3.2"
@ -1608,6 +1941,7 @@ dependencies = [
"clap", "clap",
"codespan-reporting", "codespan-reporting",
"copy_dir", "copy_dir",
"dashmap",
"env_logger", "env_logger",
"git2", "git2",
"handlebars", "handlebars",
@ -1616,14 +1950,13 @@ dependencies = [
"indexmap", "indexmap",
"jotdown", "jotdown",
"log", "log",
"pulldown-cmark",
"rand", "rand",
"rayon",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"toml_edit", "toml_edit 0.19.15",
"tower-livereload",
"treehouse-format", "treehouse-format",
"ulid", "ulid",
"url", "url",
@ -1660,15 +1993,6 @@ dependencies = [
"web-time", "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]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.15" version = "0.3.15"
@ -1713,12 +2037,29 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 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]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.4" version = "0.9.4"
@ -1977,6 +2318,21 @@ dependencies = [
"memchr", "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]] [[package]]
name = "zune-inflate" name = "zune-inflate"
version = "0.2.54" version = "0.2.54"
@ -1985,3 +2341,12 @@ checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [ dependencies = [
"simd-adler32", "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",
]

View file

@ -1,10 +1,7 @@
%% title = "404" %% title = "404"
% id = "404" % id = "01HMF8KQ997F1ZTEGDNAE2S6F1"
- # 404 - seems like the page you're looking for isn't here.
% id = "01HMF8KQ997F1ZTEGDNAE2S6F1" % id = "01HMF8KQ99XNMEP67NE3QH5698"
- seems like the page you're looking for isn't here. - care to go [back to the index][branch:treehouse]?
% id = "01HMF8KQ99XNMEP67NE3QH5698"
- care to go [back to the index][branch:treehouse]?

View file

@ -6,7 +6,7 @@
<style> <style>
@font-face { @font-face {
font-family: "Determination Sans"; font-family: "Determination Sans";
src: url('/static/font/DTM-Sans.otf?cache=b3-4fe96c14'); src: url('/static/font/DTM-Sans.otf?v=b3-4fe96c14');
} }
.undertale-save-box { .undertale-save-box {

View file

@ -0,0 +1,17 @@
%% 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")`

View file

@ -46,7 +46,7 @@ enum AllowCodeBlocks {
Yes, Yes,
} }
impl<'a> Parser<'a> { impl Parser<'_> {
fn current(&self) -> Option<char> { fn current(&self) -> Option<char> {
self.input[self.position..].chars().next() self.input[self.position..].chars().next()
} }

View file

@ -15,24 +15,22 @@ chrono = "0.4.35"
clap = { version = "4.3.22", features = ["derive"] } clap = { version = "4.3.22", features = ["derive"] }
codespan-reporting = "0.11.1" codespan-reporting = "0.11.1"
copy_dir = "0.1.3" copy_dir = "0.1.3"
dashmap = "6.1.0"
env_logger = "0.10.0" env_logger = "0.10.0"
git2 = { version = "0.19.0", default-features = false, features = ["vendored-libgit2"] } git2 = { version = "0.19.0", default-features = false, features = ["vendored-libgit2"] }
handlebars = "4.3.7" handlebars = "4.3.7"
http-body = "1.0.0" http-body = "1.0.0"
image = "0.24.8" image = "0.25.5"
indexmap = { version = "2.2.6", features = ["serde"] } indexmap = { version = "2.2.6", features = ["serde"] }
jotdown = { version = "0.4.1", default-features = false } jotdown = { version = "0.4.1", default-features = false }
log = { workspace = true } log = { workspace = true }
rand = "0.8.5" rand = "0.8.5"
rayon = "1.10.0"
regex = "1.10.3" regex = "1.10.3"
serde = { version = "1.0.183", features = ["derive"] } serde = { version = "1.0.183", features = ["derive"] }
serde_json = "1.0.105" serde_json = "1.0.105"
tokio = { version = "1.32.0", features = ["full"] } tokio = { version = "1.32.0", features = ["full"] }
toml_edit = { version = "0.19.14", features = ["serde"] } toml_edit = { version = "0.19.14", features = ["serde"] }
tower-livereload = "0.9.2"
walkdir = "2.3.3" walkdir = "2.3.3"
ulid = "1.0.0" ulid = "1.0.0"
url = "2.5.0" url = "2.5.0"
# TODO djot: To remove once migration to Djot is complete.
pulldown-cmark = { version = "0.9.3", default-features = false }

View file

@ -2,10 +2,10 @@ pub mod fix;
pub mod serve; pub mod serve;
pub mod wc; pub mod wc;
use std::path::{Path, PathBuf};
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use crate::vfs::VPathBuf;
#[derive(Parser)] #[derive(Parser)]
pub struct ProgramArgs { pub struct ProgramArgs {
#[clap(subcommand)] #[clap(subcommand)]
@ -14,9 +14,6 @@ pub struct ProgramArgs {
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Regenerate the website.
Generate(#[clap(flatten)] GenerateArgs),
/// Populate missing metadata in blocks. /// Populate missing metadata in blocks.
Fix(#[clap(flatten)] FixArgs), Fix(#[clap(flatten)] FixArgs),
@ -44,20 +41,13 @@ pub enum Command {
} }
#[derive(Args)] #[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)] #[derive(Args)]
pub struct FixArgs { pub struct FixArgs {
/// Which file to fix. The fixed file will be printed into stdout so that you have a chance to /// Which file to fix. The fixed file will be printed into stdout so that you have a chance to
/// see the changes. /// see the changes.
pub file: PathBuf, pub file: VPathBuf,
/// If you're happy with the suggested changes, specifying this will apply them to the file /// If you're happy with the suggested changes, specifying this will apply them to the file
/// (overwrite it in place.) /// (overwrite it in place.)
@ -66,7 +56,7 @@ pub struct FixArgs {
/// Write the previous version back to the specified path. /// Write the previous version back to the specified path.
#[clap(long)] #[clap(long)]
pub backup: Option<PathBuf>, pub backup: Option<VPathBuf>,
} }
#[derive(Args)] #[derive(Args)]
@ -88,17 +78,5 @@ pub struct ServeArgs {
pub struct WcArgs { pub struct WcArgs {
/// A list of paths to report the word counts of. /// A list of paths to report the word counts of.
/// If no paths are provided, the entire tree is word-counted. /// If no paths are provided, the entire tree is word-counted.
pub paths: Vec<PathBuf>, pub paths: Vec<VPathBuf>,
}
#[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,
} }

View file

@ -1,16 +1,17 @@
use std::{ffi::OsStr, ops::Range}; use std::ops::{ControlFlow, Range};
use anyhow::Context; use anyhow::{anyhow, Context};
use codespan_reporting::diagnostic::Diagnostic; use codespan_reporting::diagnostic::Diagnostic;
use log::{error, info};
use treehouse_format::ast::Branch; use treehouse_format::ast::Branch;
use walkdir::WalkDir;
use crate::{ use crate::{
parse::{self, parse_toml_with_diagnostics, parse_tree_with_diagnostics}, parse::{self, parse_toml_with_diagnostics, parse_tree_with_diagnostics},
state::{report_diagnostics, FileId, Source, Treehouse}, state::{report_diagnostics, FileId, Source, Treehouse},
vfs::{self, Dir, Edit, VPath},
}; };
use super::{FixAllArgs, FixArgs, Paths}; use super::{FixAllArgs, FixArgs};
struct Fix { struct Fix {
range: Range<usize>, range: Range<usize>,
@ -132,68 +133,102 @@ pub fn fix_file(
}) })
} }
pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> { pub fn fix_file_cli(fix_args: FixArgs, root: &dyn Dir) -> anyhow::Result<Edit> {
let utf8_filename = fix_args.file.to_string_lossy().into_owned(); let file = if &*fix_args.file == VPath::new("-") {
let file = if utf8_filename == "-" {
std::io::read_to_string(std::io::stdin().lock()).context("cannot read file from stdin")? std::io::read_to_string(std::io::stdin().lock()).context("cannot read file from stdin")?
} else { } else {
std::fs::read_to_string(&fix_args.file).context("cannot read file to fix")? String::from_utf8(
root.content(&fix_args.file)
.ok_or_else(|| anyhow!("cannot read file to fix"))?,
)
.context("input file has invalid UTF-8")?
}; };
let mut treehouse = Treehouse::new(); let mut treehouse = Treehouse::new();
let mut diagnostics = vec![]; let mut diagnostics = vec![];
let file_id = treehouse.add_file(utf8_filename, Source::Other(file)); 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
)
})?;
if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) { Ok(
if fix_args.apply { if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) {
// Try to write the backup first. If writing that fails, bail out without overwriting if fix_args.apply {
// the source file. // Try to write the backup first. If writing that fails, bail out without overwriting
if let Some(backup_path) = fix_args.backup { // the source file.
std::fs::write(backup_path, treehouse.source(file_id).input()) if let Some(backup_path) = fix_args.backup {
.context("cannot write backup; original file will not be overwritten")?; 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
} }
std::fs::write(&fix_args.file, fixed).context("cannot overwrite original file")?;
} else { } else {
println!("{fixed}"); report_diagnostics(&treehouse.files, &diagnostics)?;
} Edit::NoOp
} else { },
report_diagnostics(&treehouse.files, &diagnostics)?; )
}
Ok(())
} }
pub fn fix_all_cli(fix_all_args: FixAllArgs, paths: &Paths<'_>) -> anyhow::Result<()> { pub fn fix_all_cli(fix_all_args: FixAllArgs, dir: &dyn Dir) -> anyhow::Result<Edit> {
for entry in WalkDir::new(paths.content_dir) { let mut edits = vec![];
let entry = entry?;
if entry.file_type().is_file() && entry.path().extension() == Some(OsStr::new("tree")) { fn fix_one(dir: &dyn Dir, path: &VPath) -> anyhow::Result<Edit> {
let file = std::fs::read_to_string(entry.path()) if path.extension() == Some("tree") {
.with_context(|| format!("cannot read file to fix: {:?}", entry.path()))?; let Some(content) = dir.content(path) else {
let utf8_filename = entry.path().to_string_lossy(); return Ok(Edit::NoOp);
};
let content = String::from_utf8(content).context("file is not valid UTF-8")?;
let mut treehouse = Treehouse::new(); let mut treehouse = Treehouse::new();
let mut diagnostics = vec![]; let mut diagnostics = vec![];
let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file)); 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")?;
if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) { if let Ok(fixed) = fix_file(&mut treehouse, &mut diagnostics, file_id) {
if fixed != treehouse.source(file_id).input() { if fixed != treehouse.source(file_id).input() {
if fix_all_args.apply { return Ok(Edit::Write(edit_path, fixed));
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 { } else {
report_diagnostics(&treehouse.files, &diagnostics)?; report_diagnostics(&treehouse.files, &diagnostics)?;
} }
} }
}
if !fix_all_args.apply { Ok(Edit::NoOp)
println!("run with `--apply` to apply changes");
} }
Ok(()) 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))
}
} }

View file

@ -1,227 +1,182 @@
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
mod live_reload; mod live_reload;
use std::{net::Ipv4Addr, path::PathBuf, sync::Arc}; use std::fmt::Write;
use std::{net::Ipv4Addr, sync::Arc};
use anyhow::Context;
use axum::{ use axum::{
extract::{Path, Query, RawQuery, State}, extract::{Path, Query, RawQuery, State},
http::{ http::{
header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION}, header::{CACHE_CONTROL, CONTENT_TYPE},
HeaderValue, StatusCode, HeaderValue, StatusCode,
}, },
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::get, routing::get,
Router, Router,
}; };
use log::{error, info}; use log::info;
use pulldown_cmark::escape::escape_html;
use serde::Deserialize; use serde::Deserialize;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::{ use crate::generate::Sources;
config::Config, use crate::vfs::asynch::AsyncDir;
state::{Source, Treehouse}, use crate::vfs::VPath;
}; use crate::{html::EscapeHtml, state::Source};
use super::Paths; mod system {
use crate::vfs::VPath;
struct SystemPages { pub const INDEX: &VPath = VPath::new_const("index");
index: String, pub const FOUR_OH_FOUR: &VPath = VPath::new_const("_treehouse/404");
four_oh_four: String, pub const B_DOCS: &VPath = VPath::new_const("_treehouse/b");
b_docs: String,
sandbox: String,
navmap: String,
} }
struct Server { struct Server {
config: Config, sources: Arc<Sources>,
treehouse: Treehouse, target: AsyncDir,
target_dir: PathBuf,
system_pages: SystemPages,
} }
pub async fn serve( pub async fn serve(sources: Arc<Sources>, target: AsyncDir, port: u16) -> anyhow::Result<()> {
config: Config,
treehouse: Treehouse,
paths: &Paths<'_>,
port: u16,
) -> anyhow::Result<()> {
let app = Router::new() let app = Router::new()
.route("/", get(index)) .route("/", get(index)) // needed explicitly because * does not match empty paths
.route("/*page", get(page)) .route("/*path", get(vfs_entry))
.route("/b", get(branch)) .route("/b", get(branch))
.route("/navmap.js", get(navmap))
.route("/sandbox", get(sandbox))
.route("/static/*file", get(static_file))
.fallback(get(four_oh_four)) .fallback(get(four_oh_four))
.with_state(Arc::new(Server { .with_state(Arc::new(Server { sources, target }));
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)] #[cfg(debug_assertions)]
let app = live_reload::live_reload(app); let app = app.nest("/dev/live-reload", live_reload::router());
info!("serving on port {port}"); info!("serving on port {port}");
let listener = TcpListener::bind((Ipv4Addr::from([0u8, 0, 0, 0]), port)).await?; let listener = TcpListener::bind((Ipv4Addr::from([0u8, 0, 0, 0]), port)).await?;
Ok(axum::serve(listener, app).await?) Ok(axum::serve(listener, app).await?)
} }
fn get_content_type(path: &str) -> Option<&'static str> { fn get_content_type(extension: &str) -> Option<&'static str> {
match () { match extension {
_ if path.ends_with(".html") => Some("text/html"), "html" => Some("text/html"),
_ if path.ends_with(".js") => Some("text/javascript"), "js" => Some("text/javascript"),
_ if path.ends_with(".woff2") => Some("font/woff2"), "woff" => Some("font/woff2"),
_ if path.ends_with(".svg") => Some("image/svg+xml"), "svg" => Some("image/svg+xml"),
_ => None, _ => None,
} }
} }
async fn index(State(state): State<Arc<Server>>) -> Response { #[derive(Deserialize)]
Html(state.system_pages.index.clone()).into_response() struct VfsQuery {
#[serde(rename = "v")]
content_version: Option<String>,
} }
async fn navmap(State(state): State<Arc<Server>>) -> Response { async fn get_static_file(path: &str, query: &VfsQuery, state: &Server) -> Option<Response> {
let mut response = state.system_pages.navmap.clone().into_response(); let vpath = VPath::try_new(path).ok()?;
response let content = state.target.content(vpath).await?;
.headers_mut() let mut response = content.into_response();
.insert(CONTENT_TYPE, HeaderValue::from_static("text/javascript"));
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(
Path(path): Path<String>,
Query(query): Query<VfsQuery>,
State(state): State<Arc<Server>>,
) -> Response {
if let Some(response) = get_static_file(&path, &query, &state).await {
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"),
)
.into_response()
}
}
async fn index(State(state): State<Arc<Server>>) -> Response {
system_page(&state.target, system::INDEX, StatusCode::OK).await
} }
async fn four_oh_four(State(state): State<Arc<Server>>) -> Response { async fn four_oh_four(State(state): State<Arc<Server>>) -> Response {
( system_page(&state.target, system::FOUR_OH_FOUR, StatusCode::NOT_FOUND).await
StatusCode::NOT_FOUND,
Html(state.system_pages.four_oh_four.clone()),
)
.into_response()
} }
#[derive(Deserialize)] async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Response {
struct StaticFileQuery {
cache: Option<String>,
}
async fn static_file(
Path(path): Path<String>,
Query(query): Query<StaticFileQuery>,
State(state): State<Arc<Server>>,
) -> Response {
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 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();
}
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 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 branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Html<String> {
if let Some(named_id) = named_id { if let Some(named_id) = named_id {
let branch_id = state let branch_id = state
.sources
.treehouse .treehouse
.branches_by_named_id .branches_by_named_id
.get(&named_id) .get(&named_id)
.copied() .copied()
.or_else(|| state.treehouse.branch_redirects.get(&named_id).copied()); .or_else(|| {
state
.sources
.treehouse
.branch_redirects
.get(&named_id)
.copied()
});
if let Some(branch_id) = branch_id { if let Some(branch_id) = branch_id {
let branch = state.treehouse.tree.branch(branch_id); let branch = state.sources.treehouse.tree.branch(branch_id);
if let Source::Tree { if let Source::Tree {
input, target_path, .. input, target_path, ..
} = state.treehouse.source(branch.file_id) } = state.sources.treehouse.source(branch.file_id)
{ {
match std::fs::read_to_string(target_path) { if let Some(content) = state
Ok(content) => { .target
let branch_markdown_content = input[branch.content.clone()].trim(); .content(target_path)
let mut per_page_metadata = .await
String::from("<meta property=\"og:description\" content=\""); .and_then(|s| String::from_utf8(s).ok())
escape_html(&mut per_page_metadata, branch_markdown_content).unwrap(); {
per_page_metadata.push_str("\">"); 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("\">");
const PER_PAGE_METADATA_REPLACEMENT_STRING: &str = "<!-- treehouse-ca37057a-cff5-45b3-8415-3b02dbf6c799-per-branch-metadata -->"; const PER_PAGE_METADATA_REPLACEMENT_STRING: &str = "<!-- treehouse-ca37057a-cff5-45b3-8415-3b02dbf6c799-per-branch-metadata -->";
return Html(content.replacen( return Html(content.replacen(
PER_PAGE_METADATA_REPLACEMENT_STRING, PER_PAGE_METADATA_REPLACEMENT_STRING,
&per_page_metadata, &per_page_metadata,
// Replace one under the assumption that it appears in all pages. // Replace one under the assumption that it appears in all pages.
1, 1,
)); ))
} .into_response();
Err(e) => { } else {
error!("error while reading file {target_path:?}: {e:?}"); 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();
} }
} }
} }
Html(state.system_pages.four_oh_four.clone()) system_page(&state.target, system::FOUR_OH_FOUR, StatusCode::NOT_FOUND).await
} else { } else {
Html(state.system_pages.b_docs.clone()) system_page(&state.target, system::B_DOCS, StatusCode::OK).await
} }
} }

View file

@ -1,21 +1,28 @@
use axum::{ use std::time::Duration;
http::{header::CONTENT_TYPE, Response},
Router,
};
#[derive(Debug, Clone, Copy)] use axum::{routing::get, Router};
pub struct DisableLiveReload; use tokio::time::sleep;
pub fn live_reload(router: Router) -> Router { pub fn router<S>() -> Router<S> {
router.layer(tower_livereload::LiveReloadLayer::new().response_predicate( let router = Router::new().route("/back-up", get(back_up));
|response: &Response<_>| {
let is_html = response // The endpoint for immediate reload is only enabled on debug builds.
.headers() // Release builds use the exponential backoff system that detects is the WebSocket is closed.
.get(CONTENT_TYPE) #[cfg(debug_assertions)]
.and_then(|v| v.to_str().ok()) let router = router.route("/stall", get(stall));
.is_some_and(|v| v.starts_with("text/html"));
let is_disabled = response.extensions().get::<DisableLiveReload>().is_some(); router.with_state(())
is_html && !is_disabled }
},
)) #[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()
} }

View file

@ -1,12 +1,11 @@
use std::{ffi::OsStr, path::Path}; use std::ops::ControlFlow;
use anyhow::Context;
use treehouse_format::ast::{Branch, Roots}; use treehouse_format::ast::{Branch, Roots};
use walkdir::WalkDir;
use crate::{ use crate::{
parse::parse_tree_with_diagnostics, parse::parse_tree_with_diagnostics,
state::{report_diagnostics, Source, Treehouse}, state::{report_diagnostics, Source, Treehouse},
vfs::{self, Dir, VPath},
}; };
use super::WcArgs; use super::WcArgs;
@ -29,14 +28,14 @@ fn wc_roots(source: &str, roots: &Roots) -> usize {
.sum() .sum()
} }
pub fn wc_cli(content_dir: &Path, mut wc_args: WcArgs) -> anyhow::Result<()> { pub fn wc_cli(content_dir: &dyn Dir, mut wc_args: WcArgs) -> anyhow::Result<()> {
if wc_args.paths.is_empty() { if wc_args.paths.is_empty() {
for entry in WalkDir::new(content_dir) { vfs::walk_dir_rec(content_dir, VPath::ROOT, &mut |path| {
let entry = entry?; if path.extension() == Some("tree") {
if entry.file_type().is_file() && entry.path().extension() == Some(OsStr::new("tree")) { wc_args.paths.push(path.to_owned());
wc_args.paths.push(entry.into_path());
} }
} ControlFlow::Continue(())
});
} }
let mut treehouse = Treehouse::new(); let mut treehouse = Treehouse::new();
@ -44,24 +43,21 @@ pub fn wc_cli(content_dir: &Path, mut wc_args: WcArgs) -> anyhow::Result<()> {
let mut total = 0; let mut total = 0;
for path in &wc_args.paths { for path in &wc_args.paths {
let file = std::fs::read_to_string(path) if let Some(content) = content_dir
.with_context(|| format!("cannot read file to word count: {path:?}"))?; .content(path)
let path_without_ext = path.with_extension(""); .and_then(|b| String::from_utf8(b).ok())
let utf8_filename = path_without_ext {
.strip_prefix(content_dir) let file_id = treehouse.add_file(path.to_string(), Source::Other(content));
.expect("paths should be rooted within the content directory") match parse_tree_with_diagnostics(&mut treehouse, file_id) {
.to_string_lossy(); Ok(parsed) => {
let source = treehouse.source(file_id);
let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file)); let word_count = wc_roots(source.input(), &parsed);
match parse_tree_with_diagnostics(&mut treehouse, file_id) { println!("{word_count:>8} {}", treehouse.filename(file_id));
Ok(parsed) => { total += word_count;
let source = treehouse.source(file_id); }
let word_count = wc_roots(source.input(), &parsed); Err(diagnostics) => {
println!("{word_count:>8} {}", treehouse.filename(file_id)); report_diagnostics(&treehouse.files, &diagnostics)?;
total += word_count; }
}
Err(diagnostics) => {
report_diagnostics(&treehouse.files, &diagnostics)?;
} }
} }
} }

View file

@ -1,10 +1,8 @@
use std::{collections::HashMap, ffi::OsStr, fs::File, io::BufReader, path::Path}; use std::{collections::HashMap, ops::ControlFlow};
use anyhow::Context; use anyhow::{anyhow, Context};
use image::ImageError; use log::{debug, error};
use log::{debug, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::{ use crate::{
html::highlight::{ html::highlight::{
@ -12,7 +10,7 @@ use crate::{
Syntax, Syntax,
}, },
import_map::ImportRoot, import_map::ImportRoot,
static_urls::StaticUrls, vfs::{self, Dir, ImageSize, VPath, VPathBuf},
}; };
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -22,10 +20,6 @@ pub struct Config {
/// preferred way of setting this in production, so as not to clobber treehouse.toml.) /// preferred way of setting this in production, so as not to clobber treehouse.toml.)
pub site: String, 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. /// 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`. /// The final URL is `{commit_base_url}/{commit}/content/{tree_path}.tree`.
pub commit_base_url: String, pub commit_base_url: String,
@ -59,17 +53,17 @@ pub struct Config {
/// How the treehouse should be built. /// How the treehouse should be built.
pub build: Build, pub build: Build,
/// Overrides for emoji filenames. Useful for setting up aliases. /// Overrides for emoji names. Useful for setting up aliases.
/// ///
/// On top of this, emojis are autodiscovered by walking the `static/emoji` directory. /// Paths are anchored within `static/emoji` and must not contain parent directories.
#[serde(default)] #[serde(default)]
pub emoji: HashMap<String, String>, pub emoji: HashMap<String, VPathBuf>,
/// Overrides for pic filenames. Useful for setting up aliases. /// Overrides for pic filenames. Useful for setting up aliases.
/// ///
/// On top of this, pics are autodiscovered by walking the `static/pic` directory. /// 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. /// Only the part before the first dash is treated as the pic's id.
pub pics: HashMap<String, String>, pub pics: HashMap<String, VPathBuf>,
/// Syntax definitions. /// Syntax definitions.
/// ///
@ -105,72 +99,39 @@ pub enum Markup {
} }
impl Config { impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> { pub fn autopopulate_emoji(&mut self, dir: &dyn Dir) -> anyhow::Result<()> {
let string = std::fs::read_to_string(path).context("cannot read config file")?; vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
toml_edit::de::from_str(&string).context("error in config file") if path.extension().is_some_and(is_image_file) {
} if let Some(emoji_name) = path.file_stem() {
if !self.emoji.contains_key(emoji_name) {
fn is_emoji_file(path: &Path) -> bool { self.emoji.insert(emoji_name.to_owned(), path.to_owned());
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(()) Ok(())
} }
fn is_pic_file(path: &Path) -> bool { pub fn autopopulate_pics(&mut self, dir: &dyn Dir) -> anyhow::Result<()> {
path.extension() == Some(OsStr::new("png")) vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
|| path.extension() == Some(OsStr::new("svg")) if path.extension().is_some_and(is_image_file) {
|| path.extension() == Some(OsStr::new("jpg")) if let Some(pic_name) = path.file_stem() {
|| 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 let pic_id = pic_name
.split_once('-') .split_once('-')
.map(|(before_dash, _after_dash)| before_dash) .map(|(before_dash, _after_dash)| before_dash)
.unwrap_or(&pic_name); .unwrap_or(pic_name);
if !self.pics.contains_key(pic_id) { if !self.pics.contains_key(pic_id) {
self.pics.insert( self.pics.insert(pic_id.to_owned(), path.to_owned());
pic_id.to_owned(),
entry
.path()
.strip_prefix(dir)
.unwrap_or(entry.path())
.to_string_lossy()
.into_owned(),
);
} }
} }
} }
}
ControlFlow::Continue(())
});
Ok(()) Ok(())
} }
@ -178,79 +139,56 @@ impl Config {
format!("{}/{}", self.site, page) format!("{}/{}", self.site, page)
} }
pub fn pic_url(&self, id: &str) -> String { pub fn pic_url(&self, pics_dir: &dyn Dir, id: &str) -> String {
format!( vfs::url(
"{}/static/pic/{}", &self.site,
self.site, pics_dir,
self.pics.get(id).map(|x| &**x).unwrap_or("404.png") self.pics
.get(id)
.map(|x| &**x)
.unwrap_or(VPath::new("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. /// Loads all syntax definition files.
pub fn load_syntaxes(&mut self, dir: &Path) -> anyhow::Result<()> { pub fn load_syntaxes(&mut self, dir: &dyn Dir) -> anyhow::Result<()> {
for entry in WalkDir::new(dir) { vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
let entry = entry?; if path.extension() == Some("json") {
if entry.path().extension() == Some(OsStr::new("json")) { let name = path
let name = entry
.path()
.file_stem() .file_stem()
.expect("syntax file name should have a stem") .expect("syntax file name should have a stem due to the .json extension");
.to_string_lossy();
debug!("loading syntax {name:?}"); debug!("loading syntax {name:?}");
let syntax: Syntax = serde_json::from_reader(BufReader::new( let result: Result<Syntax, _> = dir
File::open(entry.path()).context("could not open syntax file")?, .content(path)
)) .ok_or_else(|| anyhow!("syntax .json is not a file"))
.context("could not deserialize syntax file")?; .and_then(|b| {
let compiled = compile_syntax(&syntax); String::from_utf8(b).context("syntax .json contains invalid UTF-8")
self.syntaxes.insert(name.into_owned(), compiled); })
.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}"),
}
} }
}
ControlFlow::Continue(())
});
Ok(()) Ok(())
} }
} }
/// Data derived from the config. pub fn is_image_file(extension: &str) -> bool {
pub struct ConfigDerivedData { matches!(extension, "png" | "svg" | "jpg" | "jpeg" | "webp")
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}")))
}
} }

View file

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

View file

@ -0,0 +1,37 @@
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"))
}
}
}

View file

@ -0,0 +1,37 @@
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"))
}
}
}

View file

@ -3,13 +3,12 @@ use std::fmt::{self, Display, Write};
pub mod breadcrumbs; pub mod breadcrumbs;
mod djot; mod djot;
pub mod highlight; pub mod highlight;
mod markdown;
pub mod navmap; pub mod navmap;
pub mod tree; pub mod tree;
pub struct EscapeAttribute<'a>(&'a str); pub struct EscapeAttribute<'a>(pub &'a str);
impl<'a> Display for EscapeAttribute<'a> { impl Display for EscapeAttribute<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for c in self.0.chars() { for c in self.0.chars() {
if c == '"' { if c == '"' {
@ -22,9 +21,9 @@ impl<'a> Display for EscapeAttribute<'a> {
} }
} }
pub struct EscapeHtml<'a>(&'a str); pub struct EscapeHtml<'a>(pub &'a str);
impl<'a> Display for EscapeHtml<'a> { impl Display for EscapeHtml<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for c in self.0.chars() { for c in self.0.chars() {
match c { match c {

View file

@ -17,22 +17,25 @@ use jotdown::OrderedListNumbering::*;
use jotdown::SpanLinkType; use jotdown::SpanLinkType;
use crate::config::Config; use crate::config::Config;
use crate::config::ConfigDerivedData; use crate::dirs::Dirs;
use crate::state::FileId; use crate::state::FileId;
use crate::state::Treehouse; use crate::state::Treehouse;
use crate::vfs;
use super::highlight::highlight; use super::highlight::highlight;
/// [`Render`] implementor that writes HTML output. /// [`Render`] implementor that writes HTML output.
pub struct Renderer<'a> { pub struct Renderer<'a> {
pub config: &'a Config, pub config: &'a Config,
pub config_derived_data: &'a mut ConfigDerivedData,
pub treehouse: &'a mut Treehouse, pub dirs: &'a Dirs,
pub treehouse: &'a Treehouse,
pub file_id: FileId, pub file_id: FileId,
pub page_id: String, pub page_id: String,
} }
impl<'a> Renderer<'a> { impl Renderer<'_> {
#[must_use] #[must_use]
pub fn render( pub fn render(
self, self,
@ -369,25 +372,18 @@ impl<'a> Writer<'a> {
r#"<img class="placeholder-image" loading="lazy" src=""#, r#"<img class="placeholder-image" loading="lazy" src=""#,
); );
let filename = self.renderer.config.pics.get(placeholder_pic_id); let pic_url = self
let pic_url = filename .renderer
.and_then(|filename| { .config
self.renderer .pic_url(&*self.renderer.dirs.pic, placeholder_pic_id);
.config_derived_data
.static_urls
.get(&format!("pic/{filename}"))
.ok()
})
.unwrap_or_default();
write_attr(&pic_url, out); write_attr(&pic_url, out);
out.push('"'); out.push('"');
let image_size = filename.and_then(|filename| { if let Some(image_size) = self
self.renderer .renderer
.config_derived_data .config
.image_size(&format!("static/pic/{filename}")) .pic_size(&*self.renderer.dirs.pic, placeholder_pic_id)
}); {
if let Some(image_size) = image_size {
write!( write!(
out, out,
r#" width="{}" height="{}""#, r#" width="{}" height="{}""#,
@ -482,6 +478,7 @@ impl<'a> Writer<'a> {
if !src.is_empty() { if !src.is_empty() {
out.push_str(r#"" src=""#); out.push_str(r#"" src=""#);
if let SpanLinkType::Unresolved = link_type { if let SpanLinkType::Unresolved = link_type {
// TODO: Image size.
if let Some(resolved) = self.resolve_link(src) { if let Some(resolved) = self.resolve_link(src) {
write_attr(&resolved, out); write_attr(&resolved, out);
} else { } else {
@ -523,8 +520,7 @@ impl<'a> Writer<'a> {
self.renderer.config.syntaxes.get(code_block.language) self.renderer.config.syntaxes.get(code_block.language)
}); });
if let Some(syntax) = syntax { if let Some(syntax) = syntax {
// TODO djot: make highlight infallible highlight(out, syntax, s);
highlight(out, syntax, s).map_err(|_| std::fmt::Error)?;
} else { } else {
write_text(s, out); write_text(s, out);
} }
@ -547,7 +543,7 @@ impl<'a> Writer<'a> {
}); });
} }
Event::Symbol(sym) => { Event::Symbol(sym) => {
if let Some(filename) = self.renderer.config.emoji.get(sym.as_ref()) { if let Some(vpath) = self.renderer.config.emoji.get(sym.as_ref()) {
let branch_id = self let branch_id = self
.renderer .renderer
.treehouse .treehouse
@ -565,12 +561,12 @@ impl<'a> Writer<'a> {
out.push_str(r#"">"#) out.push_str(r#"">"#)
} }
let url = self let url = vfs::url(
.renderer &self.renderer.config.site,
.config_derived_data &*self.renderer.dirs.emoji,
.static_urls vpath,
.get(&format!("emoji/{filename}")) )
.unwrap_or_default(); .expect("emoji directory is not anchored anywhere");
// TODO: this could do with better alt text // TODO: this could do with better alt text
write!( write!(
@ -580,11 +576,7 @@ impl<'a> Writer<'a> {
write_attr(&url, out); write_attr(&url, out);
out.push('"'); out.push('"');
if let Some(image_size) = self if let Some(image_size) = self.renderer.dirs.emoji.image_size(vpath) {
.renderer
.config_derived_data
.image_size(&format!("static/emoji/{filename}"))
{
write!( write!(
out, out,
r#" width="{}" height="{}""#, r#" width="{}" height="{}""#,
@ -635,10 +627,7 @@ impl<'a> Writer<'a> {
fn resolve_link(&self, link: &str) -> Option<String> { fn resolve_link(&self, link: &str) -> Option<String> {
let Renderer { let Renderer {
config, config, treehouse, ..
config_derived_data,
treehouse,
..
} = &self.renderer; } = &self.renderer;
link.split_once(':').and_then(|(kind, linked)| match kind { link.split_once(':').and_then(|(kind, linked)| match kind {
"def" => config.defs.get(linked).cloned(), "def" => config.defs.get(linked).cloned(),
@ -653,12 +642,7 @@ impl<'a> Writer<'a> {
) )
}), }),
"page" => Some(config.page_url(linked)), "page" => Some(config.page_url(linked)),
"pic" => config.pics.get(linked).and_then(|filename| { "pic" => Some(config.pic_url(&*self.renderer.dirs.pic, linked)),
config_derived_data
.static_urls
.get(&format!("pic/{filename}"))
.ok()
}),
_ => None, _ => None,
}) })
} }

View file

@ -11,13 +11,14 @@
pub mod compiled; pub mod compiled;
pub mod tokenize; pub mod tokenize;
use std::{collections::HashMap, io}; use std::{collections::HashMap, fmt::Write};
use pulldown_cmark::escape::{escape_html, StrWrite};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use self::compiled::CompiledSyntax; use self::compiled::CompiledSyntax;
use super::EscapeHtml;
/// Syntax definition. /// Syntax definition.
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Syntax { pub struct Syntax {
@ -81,14 +82,13 @@ pub struct Keyword {
pub only_replaces: Option<String>, pub only_replaces: Option<String>,
} }
pub fn highlight(mut w: impl StrWrite, syntax: &CompiledSyntax, code: &str) -> io::Result<()> { pub fn highlight(out: &mut String, syntax: &CompiledSyntax, code: &str) {
let tokens = syntax.tokenize(code); let tokens = syntax.tokenize(code);
for token in tokens { for token in tokens {
w.write_str("<span class=\"")?; out.push_str("<span class=\"");
escape_html(&mut w, &syntax.token_names[token.id])?; _ = write!(out, "{}", EscapeHtml(&syntax.token_names[token.id]));
w.write_str("\">")?; out.push_str("\">");
escape_html(&mut w, &code[token.range])?; _ = write!(out, "{}", EscapeHtml(&code[token.range]));
w.write_str("</span>")?; out.push_str("</span>");
} }
Ok(())
} }

View file

@ -1,716 +0,0 @@
// 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();
}

View file

@ -1,82 +1,72 @@
use std::collections::HashMap; use std::collections::HashMap;
use serde::Serialize;
use crate::{ use crate::{
state::Treehouse, state::Treehouse,
tree::{attributes::Content, SemaBranchId}, tree::{attributes::Content, SemaBranchId},
}; };
#[derive(Debug, Clone, Default, Serialize)] #[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)]
pub struct NavigationMap { pub struct NavigationMap {
/// Tells you which pages need to be opened to get to the key. /// Tells you which pages need to be opened to get to the key.
pub paths: HashMap<String, Vec<String>>, pub paths: HashMap<String, Vec<String>>,
} }
impl NavigationMap { impl NavigationMap {
pub fn to_javascript(&self) -> String { pub fn build(treehouse: &Treehouse, root_tree_path: &str) -> Self {
format!( let mut builder = NavigationMapBuilder::default();
"export const navigationMap = {};",
serde_json::to_string(&self.paths)
.expect("serialization of the navigation map should not fail")
)
}
}
#[derive(Debug, Clone, Default)] fn rec_branch(
pub struct NavigationMapBuilder { treehouse: &Treehouse,
stack: Vec<String>, builder: &mut NavigationMapBuilder,
navigation_map: NavigationMap, branch_id: SemaBranchId,
} ) {
let branch = treehouse.tree.branch(branch_id);
impl NavigationMapBuilder { if let Content::Link(linked) = &branch.attributes.content {
pub fn enter_tree(&mut self, tree: String) { rec_tree(treehouse, builder, linked);
self.stack.push(tree.clone()); } else {
self.navigation_map.paths.insert(tree, self.stack.clone()); for &child_id in &branch.children {
} rec_branch(treehouse, builder, child_id);
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();
} }
} }
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()
} }
rec_tree(treehouse, &mut builder, root_tree_path);
builder.finish()
} }

View file

@ -1,11 +1,10 @@
use std::{borrow::Cow, fmt::Write}; use std::{borrow::Cow, fmt::Write};
use pulldown_cmark::{BrokenLink, LinkType};
use treehouse_format::pull::BranchKind; use treehouse_format::pull::BranchKind;
use crate::{ use crate::{
cli::Paths, config::Config,
config::{Config, ConfigDerivedData, Markup}, dirs::Dirs,
html::EscapeAttribute, html::EscapeAttribute,
state::{FileId, Treehouse}, state::{FileId, Treehouse},
tree::{ tree::{
@ -14,14 +13,13 @@ use crate::{
}, },
}; };
use super::{djot, markdown, EscapeHtml}; use super::{djot, EscapeHtml};
pub fn branch_to_html( pub fn branch_to_html(
s: &mut String, s: &mut String,
treehouse: &mut Treehouse, treehouse: &Treehouse,
config: &Config, config: &Config,
config_derived_data: &mut ConfigDerivedData, dirs: &Dirs,
paths: &Paths<'_>,
file_id: FileId, file_id: FileId,
branch_id: SemaBranchId, branch_id: SemaBranchId,
) { ) {
@ -115,87 +113,28 @@ pub fn branch_to_html(
final_markup.push('\n'); 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 { if branch.attributes.template {
final_markup = mini_template::render(config, treehouse, paths, &final_markup); final_markup = mini_template::render(config, treehouse, dirs, &final_markup);
} }
s.push_str("<th-bc>"); 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(),
config, let events: Vec<_> = jotdown::Parser::new(&final_markup)
config_derived_data, .into_offset_iter()
treehouse, .collect();
file_id, // TODO: Report rendering diagnostics.
} let render_diagnostics = djot::Renderer {
.render(&events, s); page_id: treehouse
} .tree_path(file_id)
}; .expect(".tree file expected")
.to_owned(),
config,
dirs,
treehouse,
file_id,
}
.render(&events, s);
let branch = treehouse.tree.branch(branch_id); let branch = treehouse.tree.branch(branch_id);
if let Content::Link(link) = &branch.attributes.content { if let Content::Link(link) = &branch.attributes.content {
@ -247,15 +186,7 @@ pub fn branch_to_html(
let num_children = branch.children.len(); let num_children = branch.children.len();
for i in 0..num_children { for i in 0..num_children {
let child_id = treehouse.tree.branch(branch_id).children[i]; let child_id = treehouse.tree.branch(branch_id).children[i];
branch_to_html( branch_to_html(s, treehouse, config, dirs, file_id, child_id);
s,
treehouse,
config,
config_derived_data,
paths,
file_id,
child_id,
);
} }
s.push_str("</ul>"); s.push_str("</ul>");
} }
@ -269,24 +200,15 @@ pub fn branch_to_html(
pub fn branches_to_html( pub fn branches_to_html(
s: &mut String, s: &mut String,
treehouse: &mut Treehouse, treehouse: &Treehouse,
config: &Config, config: &Config,
config_derived_data: &mut ConfigDerivedData, dirs: &Dirs,
paths: &Paths<'_>,
file_id: FileId, file_id: FileId,
branches: &[SemaBranchId], branches: &[SemaBranchId],
) { ) {
s.push_str("<ul>"); s.push_str("<ul>");
for &child in branches { for &child in branches {
branch_to_html( branch_to_html(s, treehouse, config, dirs, file_id, child);
s,
treehouse,
config,
config_derived_data,
paths,
file_id,
child,
);
} }
s.push_str("</ul>"); s.push_str("</ul>");
} }

View file

@ -1,11 +1,9 @@
use std::{ffi::OsStr, path::PathBuf}; use std::ops::ControlFlow;
use indexmap::IndexMap; use indexmap::IndexMap;
use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::static_urls::StaticUrls; use crate::vfs::{self, Dir, VPathBuf};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ImportMap { pub struct ImportMap {
@ -15,49 +13,30 @@ pub struct ImportMap {
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImportRoot { pub struct ImportRoot {
pub name: String, pub name: String,
pub path: String, pub path: VPathBuf,
} }
impl ImportMap { impl ImportMap {
pub fn generate(base_url: String, import_roots: &[ImportRoot]) -> Self { pub fn generate(site: &str, root: &dyn Dir, import_roots: &[ImportRoot]) -> Self {
let mut import_map = ImportMap { let mut import_map = ImportMap {
imports: IndexMap::new(), imports: IndexMap::new(),
}; };
for root in import_roots { for import_root in import_roots {
let static_urls = StaticUrls::new( vfs::walk_dir_rec(root, &import_root.path, &mut |path| {
PathBuf::from(&root.path), if path.extension() == Some("js") {
format!("{base_url}/{}", root.path), import_map.imports.insert(
); format!(
for entry in WalkDir::new(&root.path) { "{}/{}",
let entry = match entry { import_root.name,
Ok(entry) => entry, path.strip_prefix(&import_root.path).unwrap_or(path)
Err(error) => { ),
warn!("directory walk failed: {error}"); vfs::url(site, root, path)
continue; .expect("import directory is not anchored anywhere"),
} );
};
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(); import_map.imports.sort_unstable_keys();

View file

@ -1,28 +0,0 @@
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"))
}
}

View file

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

View file

@ -1,71 +1,83 @@
use std::path::Path; use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use clap::Parser; use clap::Parser;
use cli::{ use log::error;
fix::{fix_all_cli, fix_file_cli}, use treehouse::cli::serve::serve;
serve::serve, use treehouse::dirs::Dirs;
wc::wc_cli, use treehouse::generate::{self, Sources};
Command, Paths, ProgramArgs, 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 generate::{regenerate_or_report_error, LatestRevision};
use log::{error, info, warn};
mod cli; fn vfs_sources() -> anyhow::Result<DynDir> {
mod config; let mut root = MemDir::new();
mod fun;
mod generate; root.add(
mod history; VPath::new("treehouse.toml"),
mod html; BufferedFile::new(fs::read("treehouse.toml")?).to_dyn(),
mod import_map; );
mod include_static; root.add(
mod parse; VPath::new("static"),
mod paths; PhysicalDir::new(PathBuf::from("static"))
mod state; .anchored_at(VPathBuf::new("static"))
mod static_urls; .to_dyn(),
mod tree; );
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())
}
async fn fallible_main() -> anyhow::Result<()> { async fn fallible_main() -> anyhow::Result<()> {
let args = ProgramArgs::parse(); let args = ProgramArgs::parse();
let paths = Paths { let src = vfs_sources()?;
target_dir: Path::new("target/site"), let dirs = Arc::new(Dirs {
template_target_dir: Path::new("target/site/static/html"), root: src.clone(),
content: Cd::new(src.clone(), VPathBuf::new("content")).to_dyn(),
config_file: Path::new("treehouse.toml"), static_: Cd::new(src.clone(), VPathBuf::new("static")).to_dyn(),
template: Cd::new(src.clone(), VPathBuf::new("template")).to_dyn(),
// NOTE: These are intentionally left unconfigurable from within treehouse.toml pic: Cd::new(src.clone(), VPathBuf::new("static/pic")).to_dyn(),
// because this is is one of those things that should be consistent between sites. emoji: Cd::new(src.clone(), VPathBuf::new("static/emoji")).to_dyn(),
static_dir: Path::new("static"), syntax: Cd::new(src.clone(), VPathBuf::new("static/syntax")).to_dyn(),
template_dir: Path::new("template"), });
content_dir: Path::new("content"),
};
match args.command { 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 { Command::Serve {
generate: generate_args, generate: _,
serve: serve_args, serve: serve_args,
} => { } => {
let latest_revision = match generate_args.commits_only { let sources = Arc::new(Sources::load(&dirs).context("failed to load sources")?);
true => LatestRevision::LatestCommit, let target = generate::target(dirs, sources.clone());
false => LatestRevision::WorkingTree, serve(sources, AsyncDir::new(target), serve_args.port).await?;
};
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)?, Command::Fix(fix_args) => fix_file_cli(fix_args, &*dirs.content)?.apply().await?,
Command::FixAll(fix_args) => fix_all_cli(fix_args, &paths)?, Command::FixAll(fix_args) => fix_all_cli(fix_args, &*dirs.content)?.apply().await?,
Command::Wc(wc_args) => wc_cli(paths.content_dir, wc_args)?, Command::Wc(wc_args) => wc_cli(&dirs.content, wc_args)?,
Command::Ulid => { Command::Ulid => {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();

View file

@ -1,4 +1,4 @@
use std::{collections::HashMap, ops::Range, path::PathBuf}; use std::{collections::HashMap, ops::Range};
use anyhow::Context; use anyhow::Context;
use codespan_reporting::{ use codespan_reporting::{
@ -6,26 +6,19 @@ use codespan_reporting::{
files::SimpleFiles, files::SimpleFiles,
term::termcolor::{ColorChoice, StandardStream}, term::termcolor::{ColorChoice, StandardStream},
}; };
use serde::Serialize;
use ulid::Ulid; use ulid::Ulid;
use crate::tree::{SemaBranchId, SemaRoots, SemaTree}; use crate::{
tree::{SemaBranchId, SemaRoots, SemaTree},
#[derive(Debug, Clone, Serialize)] vfs::VPathBuf,
pub struct RevisionInfo { };
pub is_latest: bool,
pub number: usize,
pub commit: String,
pub commit_short: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Source { pub enum Source {
Tree { Tree {
input: String, input: String,
tree_path: String, tree_path: String,
target_path: PathBuf, target_path: VPathBuf,
revision_info: RevisionInfo,
}, },
Other(String), Other(String),
} }
@ -103,13 +96,6 @@ 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 { pub fn next_missingno(&mut self) -> Ulid {
self.missingno_generator self.missingno_generator
.generate() .generate()
@ -117,6 +103,12 @@ impl Treehouse {
} }
} }
impl Default for Treehouse {
fn default() -> Self {
Self::new()
}
}
pub struct TomlError { pub struct TomlError {
pub message: String, pub message: String,
pub span: Option<Range<usize>>, pub span: Option<Range<usize>>,

View file

@ -1,89 +0,0 @@
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"))
}
}

View file

@ -163,13 +163,7 @@ impl SemaBranch {
) -> SemaBranchId { ) -> SemaBranchId {
let attributes = Self::parse_attributes(treehouse, diagnostics, file_id, &branch); let attributes = Self::parse_attributes(treehouse, diagnostics, file_id, &branch);
let revision_info = treehouse let named_id = attributes.id.to_owned();
.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!( let html_id = format!(
"{}:{}", "{}:{}",
treehouse.tree_path(file_id).unwrap(), treehouse.tree_path(file_id).unwrap(),

View file

@ -4,11 +4,16 @@
//! for injecting *custom, stateful* context into the renderer, which is important for things like //! for injecting *custom, stateful* context into the renderer, which is important for things like
//! the `pic` template to work. //! the `pic` template to work.
use std::fmt::Write;
use std::ops::Range; use std::ops::Range;
use pulldown_cmark::escape::escape_html; use crate::{
config::Config,
use crate::{cli::Paths, config::Config, state::Treehouse}; dirs::Dirs,
html::EscapeHtml,
state::Treehouse,
vfs::{Dir, VPath},
};
struct Lexer<'a> { struct Lexer<'a> {
input: &'a str, input: &'a str,
@ -144,12 +149,12 @@ struct Renderer<'a> {
struct InvalidTemplate; struct InvalidTemplate;
impl<'a> Renderer<'a> { impl Renderer<'_> {
fn emit_token_verbatim(&mut self, token: &Token) { fn emit_token_verbatim(&mut self, token: &Token) {
self.output.push_str(&self.lexer.input[token.range.clone()]); self.output.push_str(&self.lexer.input[token.range.clone()]);
} }
fn render(&mut self, config: &Config, treehouse: &Treehouse, paths: &Paths<'_>) { fn render(&mut self, config: &Config, treehouse: &Treehouse, dirs: &Dirs) {
let kind_of = |token: &Token| token.kind; let kind_of = |token: &Token| token.kind;
while let Some(token) = self.lexer.next() { while let Some(token) = self.lexer.next() {
@ -166,12 +171,12 @@ impl<'a> Renderer<'a> {
match Self::render_template( match Self::render_template(
config, config,
treehouse, treehouse,
paths, dirs,
self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(), self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(),
) { ) {
Ok(s) => match escaping { Ok(s) => match escaping {
EscapingMode::EscapeHtml => { EscapingMode::EscapeHtml => {
_ = escape_html(&mut self.output, &s); _ = write!(self.output, "{}", EscapeHtml(&s));
} }
EscapingMode::NoEscaping => self.output.push_str(&s), EscapingMode::NoEscaping => self.output.push_str(&s),
}, },
@ -193,24 +198,27 @@ impl<'a> Renderer<'a> {
fn render_template( fn render_template(
config: &Config, config: &Config,
_treehouse: &Treehouse, _treehouse: &Treehouse,
paths: &Paths<'_>, dirs: &Dirs,
template: &str, template: &str,
) -> Result<String, InvalidTemplate> { ) -> Result<String, InvalidTemplate> {
let (function, arguments) = template.split_once(' ').unwrap_or((template, "")); let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
match function { match function {
"pic" => Ok(config.pic_url(arguments)), "pic" => Ok(config.pic_url(&*dirs.pic, arguments)),
"include_static" => std::fs::read_to_string(paths.static_dir.join(arguments)) "include_static" => VPath::try_new(arguments)
.map_err(|_| InvalidTemplate), .ok()
.and_then(|vpath| dirs.static_.content(vpath))
.and_then(|content| String::from_utf8(content).ok())
.ok_or(InvalidTemplate),
_ => Err(InvalidTemplate), _ => Err(InvalidTemplate),
} }
} }
} }
pub fn render(config: &Config, treehouse: &Treehouse, paths: &Paths<'_>, input: &str) -> String { pub fn render(config: &Config, treehouse: &Treehouse, dirs: &Dirs, input: &str) -> String {
let mut renderer = Renderer { let mut renderer = Renderer {
lexer: Lexer::new(input), lexer: Lexer::new(input),
output: String::new(), output: String::new(),
}; };
renderer.render(config, treehouse, paths); renderer.render(config, treehouse, dirs);
renderer.output renderer.output
} }

247
crates/treehouse/src/vfs.rs Normal file
View file

@ -0,0 +1,247 @@
//! 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))
}
}

View file

@ -0,0 +1,52 @@
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)
}
}

View file

@ -0,0 +1,23 @@
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()
}
}

View file

@ -0,0 +1,62 @@
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)
}
}

View file

@ -0,0 +1,81 @@
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)
}
}

View file

@ -0,0 +1,65 @@
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)
}
}

View file

@ -0,0 +1,92 @@
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)
}
}

View file

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

View file

@ -0,0 +1,38 @@
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")
}
}

View file

@ -0,0 +1,89 @@
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)
}
}

View file

@ -0,0 +1,151 @@
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")
}
}

View file

@ -0,0 +1,58 @@
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)
}
}

View file

@ -0,0 +1,316 @@
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)
}
}

View file

@ -0,0 +1,81 @@
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())
}

View file

@ -0,0 +1 @@
mod vfs;

View file

@ -0,0 +1,5 @@
mod cd;
mod empty;
mod file;
mod mount_points;
mod physical;

View file

@ -0,0 +1,83 @@
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"))
);
}

View file

@ -0,0 +1,16 @@
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());
}

View file

@ -0,0 +1,29 @@
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()),
);
}

View file

@ -0,0 +1,88 @@
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)
);
}

View file

@ -0,0 +1,37 @@
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()));
}

View file

@ -0,0 +1 @@
hewwo :3

View file

@ -117,12 +117,12 @@ body {
Other assets are referenced rarely enough that caching probably isn't gonna make too much of Other assets are referenced rarely enough that caching probably isn't gonna make too much of
an impact. an impact.
It's unlikely I'll ever update the font anyways, so eh, whatever. */ It's unlikely I'll ever update the font anyways, so eh, whatever. */
src: url("../font/Recursive_VF_1.085.woff2?cache=b3-445487d5"); src: url("../font/Recursive_VF_1.085.woff2?v=b3-445487d5");
} }
@font-face { @font-face {
font-family: "RecVarMono"; font-family: "RecVarMono";
src: url("../font/Recursive_VF_1.085.woff2?cache=b3-445487d5"); src: url("../font/Recursive_VF_1.085.woff2?v=b3-445487d5");
font-variation-settings: "MONO" 1; font-variation-settings: "MONO" 1;
} }

16
static/js/live-reload.js Normal file
View file

@ -0,0 +1,16 @@
// 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));
}
}
});

View file

@ -104,9 +104,7 @@ class LinkedBranch extends Branch {
async loadTreePromise(_initiator) { async loadTreePromise(_initiator) {
try { try {
let response = await fetch( let response = await fetch(`${TREEHOUSE_SITE}/${this.linkedTree}`);
`${TREEHOUSE_SITE}/${this.linkedTree}.html`
);
if (response.status == 404) { if (response.status == 404) {
throw `Hmm, seems like the tree "${this.linkedTree}" does not exist.`; throw `Hmm, seems like the tree "${this.linkedTree}" does not exist.`;
} }
@ -127,7 +125,9 @@ class LinkedBranch extends Branch {
// No need to await for the import because we don't use the resulting module. // No need to await for the import because we don't use the resulting module.
// Just fire and forger 💀 // Just fire and forger 💀
// and let them run in parallel. // 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); import(url);
} }
} catch (error) { } catch (error) {
@ -257,10 +257,7 @@ async function expandLinkedBranch() {
let currentlyHighlightedBranch = getCurrentlyHighlightedBranch(); let currentlyHighlightedBranch = getCurrentlyHighlightedBranch();
if (currentlyHighlightedBranch.length > 0) { if (currentlyHighlightedBranch.length > 0) {
let linkedBranch = document.getElementById(currentlyHighlightedBranch); let linkedBranch = document.getElementById(currentlyHighlightedBranch);
if ( if (linkedBranch.children.length > 0 && linkedBranch.children[0].tagName == "DETAILS") {
linkedBranch.children.length > 0 &&
linkedBranch.children[0].tagName == "DETAILS"
) {
expandDetailsRecursively(linkedBranch.children[0]); expandDetailsRecursively(linkedBranch.children[0]);
} }
} }

View file

@ -11,10 +11,17 @@
<link rel="stylesheet" href="{{ asset 'css/icons.css' }}"> <link rel="stylesheet" href="{{ asset 'css/icons.css' }}">
<link rel="stylesheet" href="{{ asset 'css/tree.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 include_static. --}} Import maps currently don't support the src="" attribute. Unless we come up with something
{{!-- <script type="importmap" src="{{ asset 'generated/import-map.json' }}"></script> --}} clever to do while browser vendors figure that out, we'll just have to do a cache-busting string substitution.
<script type="importmap">{{{ include_static 'generated/import-map.json' }}}</script> --}}
<script type="importmap">{{{ import_map }}}</script>
{{#if dev}}
<script type="module">
import "treehouse/live-reload.js";
</script>
{{/if}}
<script> <script>
const TREEHOUSE_SITE = `{{ config.site }}`; const TREEHOUSE_SITE = `{{ config.site }}`;

View file

@ -3,6 +3,8 @@
<html> <html>
<head> <head>
<meta charset="UTF-8">
<title>treehouse iframe sandbox</title> <title>treehouse iframe sandbox</title>
<link rel="stylesheet" href="{{ asset 'css/base.css' }}"> <link rel="stylesheet" href="{{ asset 'css/base.css' }}">
@ -21,7 +23,7 @@
} }
</style> </style>
<script type="importmap">{{{ include_static 'generated/import-map.json' }}}</script> <script type="importmap">{{{ import_map }}}</script>
<script type="module"> <script type="module">
import { evaluate, domConsole, jsConsole } from "treehouse/components/literate-programming/eval.js"; import { evaluate, domConsole, jsConsole } from "treehouse/components/literate-programming/eval.js";

View file

@ -5,9 +5,6 @@
# This variable can also be set using the TREEHOUSE_SITE environment variable. # This variable can also be set using the TREEHOUSE_SITE environment variable.
site = "" 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. # 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`. # The final URL is `{commit_base_url}/{commit}/content/{tree_path}.tree`.
commit_base_url = "https://src.liquidev.net/liquidex/treehouse/src/commit" commit_base_url = "https://src.liquidev.net/liquidex/treehouse/src/commit"
@ -60,7 +57,7 @@ description = "a place on the Internet I like to call home"
[build.javascript] [build.javascript]
import_roots = [ import_roots = [
{ name = "treehouse", path = "static/js" }, { name = "treehouse", path = "" },
{ name = "tairu", path = "static/js/components/tairu" }, { name = "tairu", path = "components/tairu" },
{ name = "haku", path = "static/js/components/haku" }, { name = "haku", path = "components/haku" },
] ]