Compare commits

..

No commits in common. "5193fc2be0f58583d720bc721e8b042c69983719" and "86b4bf5b2ddb38f3b5fdf362da3c3e75704bfd48" have entirely different histories.

58 changed files with 2346 additions and 3210 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 = 4 version = 3
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@ -26,12 +26,6 @@ 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"
@ -102,23 +96,6 @@ 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"
@ -127,9 +104,9 @@ checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
@ -148,29 +125,6 @@ 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"
@ -265,12 +219,6 @@ 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"
@ -293,12 +241,6 @@ 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"
@ -312,10 +254,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5" checksum = "78834c15cb5d5efe3452d58b1e8ba890dd62d21907f867f383358198e56ebca5"
[[package]] [[package]]
name = "byteorder-lite" name = "byteorder"
version = "0.1.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
@ -334,16 +276,6 @@ 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"
@ -506,20 +438,6 @@ 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"
@ -863,43 +781,22 @@ dependencies = [
[[package]] [[package]]
name = "image" name = "image"
version = "0.25.5" version = "0.24.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder",
"color_quant", "color_quant",
"exr", "exr",
"gif", "gif",
"image-webp", "jpeg-decoder",
"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"
@ -911,17 +808,6 @@ 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"
@ -939,15 +825,6 @@ 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"
@ -974,6 +851,9 @@ 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"
@ -996,16 +876,6 @@ 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"
@ -1046,31 +916,12 @@ 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"
@ -1083,12 +934,6 @@ 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"
@ -1110,69 +955,6 @@ 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"
@ -1230,12 +1012,6 @@ 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"
@ -1354,22 +1130,14 @@ dependencies = [
] ]
[[package]] [[package]]
name = "profiling" name = "pulldown-cmark"
version = "1.0.16" version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b"
dependencies = [ dependencies = [
"profiling-procmacros", "bitflags 2.5.0",
] "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]]
@ -1381,12 +1149,6 @@ 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"
@ -1426,56 +1188,6 @@ 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"
@ -1534,12 +1246,6 @@ 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"
@ -1667,15 +1373,6 @@ 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"
@ -1730,25 +1427,6 @@ 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"
@ -1834,18 +1512,6 @@ 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"
@ -1865,20 +1531,7 @@ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
"winnow 0.5.40", "winnow",
]
[[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]]
@ -1903,6 +1556,20 @@ 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"
@ -1941,7 +1608,6 @@ dependencies = [
"clap", "clap",
"codespan-reporting", "codespan-reporting",
"copy_dir", "copy_dir",
"dashmap",
"env_logger", "env_logger",
"git2", "git2",
"handlebars", "handlebars",
@ -1950,13 +1616,14 @@ dependencies = [
"indexmap", "indexmap",
"jotdown", "jotdown",
"log", "log",
"pulldown-cmark",
"rand", "rand",
"rayon",
"regex", "regex",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"toml_edit 0.19.15", "toml_edit",
"tower-livereload",
"treehouse-format", "treehouse-format",
"ulid", "ulid",
"url", "url",
@ -1993,6 +1660,15 @@ 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"
@ -2037,29 +1713,12 @@ 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"
@ -2318,21 +1977,6 @@ 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"
@ -2341,12 +1985,3 @@ 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,7 +1,10 @@
%% title = "404" %% title = "404"
% id = "01HMF8KQ997F1ZTEGDNAE2S6F1" % id = "404"
- seems like the page you're looking for isn't here. - # 404
% id = "01HMF8KQ99XNMEP67NE3QH5698" % id = "01HMF8KQ997F1ZTEGDNAE2S6F1"
- care to go [back to the index][branch:treehouse]? - seems like the page you're looking for isn't here.
% 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?v=b3-4fe96c14'); src: url('/static/font/DTM-Sans.otf?cache=b3-4fe96c14');
} }
.undertale-save-box { .undertale-save-box {

View file

@ -1,17 +0,0 @@
%% title = "treehouse virtual file system design"
- notes on the design; this is not an actual listing of the virtual file system
- `content` - `GitDir(".", "content")`
- `GitDir` is a special filesystem which makes all files have subpaths with commit data sourced from git.
their entries are ordered by how new/old a commit is
- `inner/<commit>` - contains the file content and a revision info fork
- `inner/latest` - same but for the latest revision, if applicable.
this may be the working tree
- `template` - `PhysicalDir("template")`
- `static` - `PhysicalDir("static")`

View file

@ -46,7 +46,7 @@ enum AllowCodeBlocks {
Yes, Yes,
} }
impl Parser<'_> { impl<'a> Parser<'a> {
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,22 +15,24 @@ 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.25.5" image = "0.24.8"
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,9 +2,9 @@ pub mod fix;
pub mod serve; pub mod serve;
pub mod wc; pub mod wc;
use clap::{Args, Parser, Subcommand}; use std::path::{Path, PathBuf};
use crate::vfs::VPathBuf; use clap::{Args, Parser, Subcommand};
#[derive(Parser)] #[derive(Parser)]
pub struct ProgramArgs { pub struct ProgramArgs {
@ -14,6 +14,9 @@ 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),
@ -41,13 +44,20 @@ 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: VPathBuf, pub file: PathBuf,
/// 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.)
@ -56,7 +66,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<VPathBuf>, pub backup: Option<PathBuf>,
} }
#[derive(Args)] #[derive(Args)]
@ -78,5 +88,17 @@ 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<VPathBuf>, pub paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy)]
pub struct Paths<'a> {
pub target_dir: &'a Path,
pub template_target_dir: &'a Path,
pub static_dir: &'a Path,
pub template_dir: &'a Path,
pub content_dir: &'a Path,
pub config_file: &'a Path,
} }

View file

@ -1,17 +1,16 @@
use std::ops::{ControlFlow, Range}; use std::{ffi::OsStr, ops::Range};
use anyhow::{anyhow, Context}; use anyhow::Context;
use codespan_reporting::diagnostic::Diagnostic; use 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}; use super::{FixAllArgs, FixArgs, Paths};
struct Fix { struct Fix {
range: Range<usize>, range: Range<usize>,
@ -133,102 +132,68 @@ pub fn fix_file(
}) })
} }
pub fn fix_file_cli(fix_args: FixArgs, root: &dyn Dir) -> anyhow::Result<Edit> { pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> {
let file = if &*fix_args.file == VPath::new("-") { let utf8_filename = fix_args.file.to_string_lossy().into_owned();
let file = if utf8_filename == "-" {
std::io::read_to_string(std::io::stdin().lock()).context("cannot read file from stdin")? std::io::read_to_string(std::io::stdin().lock()).context("cannot read file from stdin")?
} else { } else {
String::from_utf8( std::fs::read_to_string(&fix_args.file).context("cannot read file to fix")?
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(fix_args.file.as_str().to_owned(), Source::Other(file)); let file_id = treehouse.add_file(utf8_filename, 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
)
})?;
Ok( 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 fix_args.apply {
if fix_args.apply { // Try to write the backup first. If writing that fails, bail out without overwriting
// Try to write the backup first. If writing that fails, bail out without overwriting // the source file.
// the source file. if let Some(backup_path) = fix_args.backup {
if let Some(backup_path) = fix_args.backup { std::fs::write(backup_path, treehouse.source(file_id).input())
let backup_edit_path = root.edit_path(&backup_path).ok_or_else(|| { .context("cannot write backup; original file will not be overwritten")?;
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 {
report_diagnostics(&treehouse.files, &diagnostics)?; println!("{fixed}");
Edit::NoOp }
}, } else {
) report_diagnostics(&treehouse.files, &diagnostics)?;
}
Ok(())
} }
pub fn fix_all_cli(fix_all_args: FixAllArgs, dir: &dyn Dir) -> anyhow::Result<Edit> { pub fn fix_all_cli(fix_all_args: FixAllArgs, paths: &Paths<'_>) -> anyhow::Result<()> {
let mut edits = vec![]; for entry in WalkDir::new(paths.content_dir) {
let entry = entry?;
fn fix_one(dir: &dyn Dir, path: &VPath) -> anyhow::Result<Edit> { if entry.file_type().is_file() && entry.path().extension() == Some(OsStr::new("tree")) {
if path.extension() == Some("tree") { let file = std::fs::read_to_string(entry.path())
let Some(content) = dir.content(path) else { .with_context(|| format!("cannot read file to fix: {:?}", entry.path()))?;
return Ok(Edit::NoOp); let utf8_filename = entry.path().to_string_lossy();
};
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(path.as_str().to_string(), Source::Other(content)); let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file));
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() {
return Ok(Edit::Write(edit_path, fixed)); if fix_all_args.apply {
println!("fixing: {:?}", entry.path());
std::fs::write(entry.path(), fixed).with_context(|| {
format!("cannot overwrite original file: {:?}", entry.path())
})?;
} else {
println!("will fix: {:?}", entry.path());
}
} }
} else { } else {
report_diagnostics(&treehouse.files, &diagnostics)?; report_diagnostics(&treehouse.files, &diagnostics)?;
} }
} }
Ok(Edit::NoOp)
} }
info!("gathering edits");
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| {
match fix_one(dir, path) {
Ok(Edit::NoOp) => (),
Ok(edit) => edits.push(edit),
Err(err) => error!("cannot fix {path}: {err:?}"),
}
ControlFlow::Continue(())
});
// NOTE: This number may be higher than you expect, because NoOp edits also count!
info!("{} edits to apply", edits.len());
if !fix_all_args.apply { if !fix_all_args.apply {
info!("dry run; add `--apply` to apply changes"); println!("run with `--apply` to apply changes");
Ok(Edit::Dry(Box::new(Edit::All(edits))))
} else {
Ok(Edit::All(edits))
} }
Ok(())
} }

View file

@ -1,182 +1,227 @@
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
mod live_reload; mod live_reload;
use std::fmt::Write; use std::{net::Ipv4Addr, path::PathBuf, sync::Arc};
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}, header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION},
HeaderValue, StatusCode, HeaderValue, StatusCode,
}, },
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::get, routing::get,
Router, Router,
}; };
use log::info; use log::{error, info};
use pulldown_cmark::escape::escape_html;
use serde::Deserialize; use serde::Deserialize;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use crate::generate::Sources; use crate::{
use crate::vfs::asynch::AsyncDir; config::Config,
use crate::vfs::VPath; state::{Source, Treehouse},
use crate::{html::EscapeHtml, state::Source}; };
mod system { use super::Paths;
use crate::vfs::VPath;
pub const INDEX: &VPath = VPath::new_const("index"); struct SystemPages {
pub const FOUR_OH_FOUR: &VPath = VPath::new_const("_treehouse/404"); index: String,
pub const B_DOCS: &VPath = VPath::new_const("_treehouse/b"); four_oh_four: String,
b_docs: String,
sandbox: String,
navmap: String,
} }
struct Server { struct Server {
sources: Arc<Sources>, config: Config,
target: AsyncDir, treehouse: Treehouse,
target_dir: PathBuf,
system_pages: SystemPages,
} }
pub async fn serve(sources: Arc<Sources>, target: AsyncDir, port: u16) -> anyhow::Result<()> { pub async fn serve(
config: Config,
treehouse: Treehouse,
paths: &Paths<'_>,
port: u16,
) -> anyhow::Result<()> {
let app = Router::new() let app = Router::new()
.route("/", get(index)) // needed explicitly because * does not match empty paths .route("/", get(index))
.route("/*path", get(vfs_entry)) .route("/*page", get(page))
.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 { sources, target })); .with_state(Arc::new(Server {
config,
treehouse,
target_dir: paths.target_dir.to_owned(),
system_pages: SystemPages {
index: std::fs::read_to_string(paths.target_dir.join("index.html"))
.context("cannot read index page")?,
four_oh_four: std::fs::read_to_string(paths.target_dir.join("_treehouse/404.html"))
.context("cannot read 404 page")?,
b_docs: std::fs::read_to_string(paths.target_dir.join("_treehouse/b.html"))
.context("cannot read /b documentation page")?,
sandbox: std::fs::read_to_string(paths.target_dir.join("static/html/sandbox.html"))
.context("cannot read sandbox page")?,
navmap: std::fs::read_to_string(paths.target_dir.join("navmap.js"))
.context("cannot read navigation map")?,
},
}));
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let app = app.nest("/dev/live-reload", live_reload::router()); let app = live_reload::live_reload(app);
info!("serving on port {port}"); 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(extension: &str) -> Option<&'static str> { fn get_content_type(path: &str) -> Option<&'static str> {
match extension { match () {
"html" => Some("text/html"), _ if path.ends_with(".html") => Some("text/html"),
"js" => Some("text/javascript"), _ if path.ends_with(".js") => Some("text/javascript"),
"woff" => Some("font/woff2"), _ if path.ends_with(".woff2") => Some("font/woff2"),
"svg" => Some("image/svg+xml"), _ if path.ends_with(".svg") => Some("image/svg+xml"),
_ => None, _ => None,
} }
} }
async fn index(State(state): State<Arc<Server>>) -> Response {
Html(state.system_pages.index.clone()).into_response()
}
async fn navmap(State(state): State<Arc<Server>>) -> Response {
let mut response = state.system_pages.navmap.clone().into_response();
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/javascript"));
response
}
async fn four_oh_four(State(state): State<Arc<Server>>) -> Response {
(
StatusCode::NOT_FOUND,
Html(state.system_pages.four_oh_four.clone()),
)
.into_response()
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct VfsQuery { struct StaticFileQuery {
#[serde(rename = "v")] cache: Option<String>,
content_version: Option<String>,
} }
async fn get_static_file(path: &str, query: &VfsQuery, state: &Server) -> Option<Response> { async fn static_file(
let vpath = VPath::try_new(path).ok()?;
let content = state.target.content(vpath).await?;
let mut response = content.into_response();
if let Some(content_type) = vpath.extension().and_then(get_content_type) {
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
} else {
response.headers_mut().remove(CONTENT_TYPE);
}
if query.content_version.is_some() {
response.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
Some(response)
}
async fn vfs_entry(
Path(path): Path<String>, Path(path): Path<String>,
Query(query): Query<VfsQuery>, Query(query): Query<StaticFileQuery>,
State(state): State<Arc<Server>>, State(state): State<Arc<Server>>,
) -> Response { ) -> Response {
if let Some(response) = get_static_file(&path, &query, &state).await { if let Ok(file) = tokio::fs::read(state.target_dir.join("static").join(&path)).await {
let mut response = file.into_response();
if let Some(content_type) = get_content_type(&path) {
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
} else {
response.headers_mut().remove(CONTENT_TYPE);
}
if query.cache.is_some() {
response.headers_mut().insert(
CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
);
}
response response
} else { } else {
four_oh_four(State(state)).await four_oh_four(State(state)).await
} }
} }
async fn system_page(target: &AsyncDir, path: &VPath, status_code: StatusCode) -> Response { async fn page(Path(path): Path<String>, State(state): State<Arc<Server>>) -> Response {
if let Some(content) = target.content(path).await { let bare_path = path.strip_suffix(".html").unwrap_or(&path);
(status_code, Html(content)).into_response() if let Some(redirected_path) = state.config.redirects.page.get(bare_path) {
} else { return (
( StatusCode::MOVED_PERMANENTLY,
StatusCode::INTERNAL_SERVER_ERROR, [(LOCATION, format!("{}/{redirected_path}", state.config.site))],
format!("500 Internal Server Error: system page {path} is not available"),
) )
.into_response() .into_response();
}
let html_path = format!("{bare_path}.html");
if let Ok(file) = tokio::fs::read(state.target_dir.join(&*html_path)).await {
([(CONTENT_TYPE, "text/html")], file).into_response()
} else {
four_oh_four(State(state)).await
} }
} }
async fn index(State(state): State<Arc<Server>>) -> Response { async fn sandbox(State(state): State<Arc<Server>>) -> Response {
system_page(&state.target, system::INDEX, StatusCode::OK).await // Small hack to prevent the LiveReloadLayer from injecting itself into the sandbox.
// The sandbox is always nested under a different page, so there's no need to do that.
let mut response = Html(state.system_pages.sandbox.clone()).into_response();
#[cfg(debug_assertions)]
{
response
.extensions_mut()
.insert(live_reload::DisableLiveReload);
}
// Debounce requests a bit. There's a tendency to have very many sandboxes on a page, and
// loading this page as many times as there are sandboxes doesn't seem like the best way to do
// things.
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=10"));
response
} }
async fn four_oh_four(State(state): State<Arc<Server>>) -> Response { async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Html<String> {
system_page(&state.target, system::FOUR_OH_FOUR, StatusCode::NOT_FOUND).await
}
async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Response {
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(|| { .or_else(|| state.treehouse.branch_redirects.get(&named_id).copied());
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.sources.treehouse.tree.branch(branch_id); let branch = state.treehouse.tree.branch(branch_id);
if let Source::Tree { if let Source::Tree {
input, target_path, .. input, target_path, ..
} = state.sources.treehouse.source(branch.file_id) } = state.treehouse.source(branch.file_id)
{ {
if let Some(content) = state match std::fs::read_to_string(target_path) {
.target Ok(content) => {
.content(target_path) let branch_markdown_content = input[branch.content.clone()].trim();
.await let mut per_page_metadata =
.and_then(|s| String::from_utf8(s).ok()) String::from("<meta property=\"og:description\" content=\"");
{ escape_html(&mut per_page_metadata, branch_markdown_content).unwrap();
let branch_markup = input[branch.content.clone()].trim(); per_page_metadata.push_str("\">");
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(); }
} else { Err(e) => {
return ( error!("error while reading file {target_path:?}: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR, }
format!("500 Internal Server Error: branch metadata points to entry {target_path} which does not have readable content")
)
.into_response();
} }
} }
} }
system_page(&state.target, system::FOUR_OH_FOUR, StatusCode::NOT_FOUND).await Html(state.system_pages.four_oh_four.clone())
} else { } else {
system_page(&state.target, system::B_DOCS, StatusCode::OK).await Html(state.system_pages.b_docs.clone())
} }
} }

View file

@ -1,28 +1,21 @@
use std::time::Duration; use axum::{
http::{header::CONTENT_TYPE, Response},
Router,
};
use axum::{routing::get, Router}; #[derive(Debug, Clone, Copy)]
use tokio::time::sleep; pub struct DisableLiveReload;
pub fn router<S>() -> Router<S> { pub fn live_reload(router: Router) -> Router {
let router = Router::new().route("/back-up", get(back_up)); router.layer(tower_livereload::LiveReloadLayer::new().response_predicate(
|response: &Response<_>| {
// The endpoint for immediate reload is only enabled on debug builds. let is_html = response
// Release builds use the exponential backoff system that detects is the WebSocket is closed. .headers()
#[cfg(debug_assertions)] .get(CONTENT_TYPE)
let router = router.route("/stall", get(stall)); .and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
router.with_state(()) let is_disabled = response.extensions().get::<DisableLiveReload>().is_some();
} 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,11 +1,12 @@
use std::ops::ControlFlow; use std::{ffi::OsStr, path::Path};
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;
@ -28,14 +29,14 @@ fn wc_roots(source: &str, roots: &Roots) -> usize {
.sum() .sum()
} }
pub fn wc_cli(content_dir: &dyn Dir, mut wc_args: WcArgs) -> anyhow::Result<()> { pub fn wc_cli(content_dir: &Path, mut wc_args: WcArgs) -> anyhow::Result<()> {
if wc_args.paths.is_empty() { if wc_args.paths.is_empty() {
vfs::walk_dir_rec(content_dir, VPath::ROOT, &mut |path| { for entry in WalkDir::new(content_dir) {
if path.extension() == Some("tree") { let entry = entry?;
wc_args.paths.push(path.to_owned()); if entry.file_type().is_file() && entry.path().extension() == Some(OsStr::new("tree")) {
wc_args.paths.push(entry.into_path());
} }
ControlFlow::Continue(()) }
});
} }
let mut treehouse = Treehouse::new(); let mut treehouse = Treehouse::new();
@ -43,21 +44,24 @@ pub fn wc_cli(content_dir: &dyn Dir, mut wc_args: WcArgs) -> anyhow::Result<()>
let mut total = 0; let mut total = 0;
for path in &wc_args.paths { for path in &wc_args.paths {
if let Some(content) = content_dir let file = std::fs::read_to_string(path)
.content(path) .with_context(|| format!("cannot read file to word count: {path:?}"))?;
.and_then(|b| String::from_utf8(b).ok()) let path_without_ext = path.with_extension("");
{ let utf8_filename = path_without_ext
let file_id = treehouse.add_file(path.to_string(), Source::Other(content)); .strip_prefix(content_dir)
match parse_tree_with_diagnostics(&mut treehouse, file_id) { .expect("paths should be rooted within the content directory")
Ok(parsed) => { .to_string_lossy();
let source = treehouse.source(file_id);
let word_count = wc_roots(source.input(), &parsed); let file_id = treehouse.add_file(utf8_filename.into_owned(), Source::Other(file));
println!("{word_count:>8} {}", treehouse.filename(file_id)); match parse_tree_with_diagnostics(&mut treehouse, file_id) {
total += word_count; Ok(parsed) => {
} let source = treehouse.source(file_id);
Err(diagnostics) => { let word_count = wc_roots(source.input(), &parsed);
report_diagnostics(&treehouse.files, &diagnostics)?; println!("{word_count:>8} {}", treehouse.filename(file_id));
} total += word_count;
}
Err(diagnostics) => {
report_diagnostics(&treehouse.files, &diagnostics)?;
} }
} }
} }

View file

@ -1,8 +1,10 @@
use std::{collections::HashMap, ops::ControlFlow}; use std::{collections::HashMap, ffi::OsStr, fs::File, io::BufReader, path::Path};
use anyhow::{anyhow, Context}; use anyhow::Context;
use log::{debug, error}; use image::ImageError;
use log::{debug, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::{ use crate::{
html::highlight::{ html::highlight::{
@ -10,7 +12,7 @@ use crate::{
Syntax, Syntax,
}, },
import_map::ImportRoot, import_map::ImportRoot,
vfs::{self, Dir, ImageSize, VPath, VPathBuf}, static_urls::StaticUrls,
}; };
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -20,6 +22,10 @@ pub struct Config {
/// preferred way of setting this in production, so as not to clobber treehouse.toml.) /// 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,
@ -53,17 +59,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 names. Useful for setting up aliases. /// Overrides for emoji filenames. Useful for setting up aliases.
/// ///
/// Paths are anchored within `static/emoji` and must not contain parent directories. /// On top of this, emojis are autodiscovered by walking the `static/emoji` directory.
#[serde(default)] #[serde(default)]
pub emoji: HashMap<String, VPathBuf>, pub emoji: HashMap<String, String>,
/// 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, VPathBuf>, pub pics: HashMap<String, String>,
/// Syntax definitions. /// Syntax definitions.
/// ///
@ -99,39 +105,72 @@ pub enum Markup {
} }
impl Config { impl Config {
pub fn autopopulate_emoji(&mut self, dir: &dyn Dir) -> anyhow::Result<()> { pub fn load(path: &Path) -> anyhow::Result<Self> {
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| { let string = std::fs::read_to_string(path).context("cannot read config file")?;
if path.extension().is_some_and(is_image_file) { toml_edit::de::from_str(&string).context("error in config file")
if let Some(emoji_name) = path.file_stem() { }
if !self.emoji.contains_key(emoji_name) {
self.emoji.insert(emoji_name.to_owned(), path.to_owned()); fn is_emoji_file(path: &Path) -> bool {
path.extension() == Some(OsStr::new("png")) || path.extension() == Some(OsStr::new("svg"))
}
pub fn autopopulate_emoji(&mut self, dir: &Path) -> anyhow::Result<()> {
for file in WalkDir::new(dir) {
let entry = file?;
if entry.file_type().is_file() && Self::is_emoji_file(entry.path()) {
if let Some(emoji_name) = entry.path().file_stem() {
let emoji_name = emoji_name.to_string_lossy();
if !self.emoji.contains_key(emoji_name.as_ref()) {
self.emoji.insert(
emoji_name.into_owned(),
entry
.path()
.strip_prefix(dir)
.unwrap_or(entry.path())
.to_string_lossy()
.into_owned(),
);
} }
} }
} }
}
ControlFlow::Continue(())
});
Ok(()) Ok(())
} }
pub fn autopopulate_pics(&mut self, dir: &dyn Dir) -> anyhow::Result<()> { fn is_pic_file(path: &Path) -> bool {
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| { path.extension() == Some(OsStr::new("png"))
if path.extension().is_some_and(is_image_file) { || path.extension() == Some(OsStr::new("svg"))
if let Some(pic_name) = path.file_stem() { || path.extension() == Some(OsStr::new("jpg"))
|| path.extension() == Some(OsStr::new("jpeg"))
|| path.extension() == Some(OsStr::new("webp"))
}
pub fn autopopulate_pics(&mut self, dir: &Path) -> anyhow::Result<()> {
for file in WalkDir::new(dir) {
let entry = file?;
if entry.file_type().is_file() && Self::is_pic_file(entry.path()) {
if let Some(pic_name) = entry.path().file_stem() {
let pic_name = pic_name.to_string_lossy();
let pic_id = pic_name 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(pic_id.to_owned(), path.to_owned()); self.pics.insert(
pic_id.to_owned(),
entry
.path()
.strip_prefix(dir)
.unwrap_or(entry.path())
.to_string_lossy()
.into_owned(),
);
} }
} }
} }
}
ControlFlow::Continue(())
});
Ok(()) Ok(())
} }
@ -139,56 +178,79 @@ impl Config {
format!("{}/{}", self.site, page) format!("{}/{}", self.site, page)
} }
pub fn pic_url(&self, pics_dir: &dyn Dir, id: &str) -> String { pub fn pic_url(&self, id: &str) -> String {
vfs::url( format!(
&self.site, "{}/static/pic/{}",
pics_dir, self.site,
self.pics self.pics.get(id).map(|x| &**x).unwrap_or("404.png")
.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: &dyn Dir) -> anyhow::Result<()> { pub fn load_syntaxes(&mut self, dir: &Path) -> anyhow::Result<()> {
vfs::walk_dir_rec(dir, VPath::ROOT, &mut |path| { for entry in WalkDir::new(dir) {
if path.extension() == Some("json") { let entry = entry?;
let name = path if entry.path().extension() == Some(OsStr::new("json")) {
let name = entry
.path()
.file_stem() .file_stem()
.expect("syntax file name should have a stem due to the .json extension"); .expect("syntax file name should have a stem")
.to_string_lossy();
debug!("loading syntax {name:?}"); debug!("loading syntax {name:?}");
let result: Result<Syntax, _> = dir let syntax: Syntax = serde_json::from_reader(BufReader::new(
.content(path) File::open(entry.path()).context("could not open syntax file")?,
.ok_or_else(|| anyhow!("syntax .json is not a file")) ))
.and_then(|b| { .context("could not deserialize syntax file")?;
String::from_utf8(b).context("syntax .json contains invalid UTF-8") let compiled = compile_syntax(&syntax);
}) 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(())
} }
} }
pub fn is_image_file(extension: &str) -> bool { /// Data derived from the config.
matches!(extension, "png" | "svg" | "jpg" | "jpeg" | "webp") pub struct ConfigDerivedData {
pub image_sizes: HashMap<String, Option<ImageSize>>,
pub static_urls: StaticUrls,
}
/// Image size. This is useful for emitting <img> elements with a specific size to eliminate
/// layout shifting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageSize {
pub width: u32,
pub height: u32,
}
impl ConfigDerivedData {
fn read_image_size(filename: &str) -> Option<ImageSize> {
let (width, height) = image::io::Reader::new(BufReader::new(File::open(filename).ok()?))
.with_guessed_format()
.map_err(ImageError::from)
.and_then(|i| i.into_dimensions())
// NOTE: Not being able to determine the image size is not the end of the world,
// so just warn the user if we couldn't do it.
// For example, currently SVG is not supported at all, which causes this to fail.
.inspect_err(|e| warn!("cannot read image size of {filename}: {e}"))
.ok()?;
Some(ImageSize { width, height })
}
pub fn image_size(&mut self, filename: &str) -> Option<ImageSize> {
if !self.image_sizes.contains_key(filename) {
self.image_sizes
.insert(filename.to_owned(), Self::read_image_size(filename));
}
self.image_sizes.get(filename).copied().flatten()
}
pub fn pic_size(&mut self, config: &Config, pic_id: &str) -> Option<ImageSize> {
config
.pics
.get(pic_id)
.and_then(|pic_filename| self.image_size(&format!("static/pic/{pic_filename}")))
}
} }

View file

@ -1,15 +0,0 @@
use crate::vfs::DynDir;
#[derive(Debug, Clone)]
pub struct Dirs {
pub root: DynDir,
pub content: DynDir,
pub static_: DynDir,
pub template: DynDir,
// `static` directories
pub pic: DynDir,
pub emoji: DynDir,
pub syntax: DynDir,
}

File diff suppressed because it is too large Load diff

View file

@ -1,37 +0,0 @@
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
use serde_json::Value;
use crate::vfs::{self, DynDir, VPath};
pub struct DirHelper {
site: String,
dir: DynDir,
}
impl DirHelper {
pub fn new(site: &str, dir: DynDir) -> Self {
Self {
site: site.to_owned(),
dir,
}
}
}
impl HelperDef for DirHelper {
fn call_inner<'reg: 'rc, 'rc>(
&self,
h: &Helper<'reg, 'rc>,
_: &'reg Handlebars<'reg>,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
if let Some(path) = h.param(0).and_then(|v| v.value().as_str()) {
let vpath = VPath::try_new(path).map_err(|e| RenderError::new(e.to_string()))?;
let url = vfs::url(&self.site, &self.dir, vpath)
.ok_or_else(|| RenderError::new("path is not anchored anywhere"))?;
Ok(ScopedJson::Derived(Value::String(url)))
} else {
Err(RenderError::new("missing path string"))
}
}
}

View file

@ -1,37 +0,0 @@
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
use serde_json::Value;
use crate::vfs::{DynDir, VPath};
pub struct IncludeStaticHelper {
dir: DynDir,
}
impl IncludeStaticHelper {
pub fn new(dir: DynDir) -> Self {
Self { dir }
}
}
impl HelperDef for IncludeStaticHelper {
fn call_inner<'reg: 'rc, 'rc>(
&self,
h: &Helper<'reg, 'rc>,
_: &'reg Handlebars<'reg>,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
if let Some(path) = h.param(0).and_then(|v| v.value().as_str()) {
let vpath = VPath::try_new(path).map_err(|e| RenderError::new(e.to_string()))?;
let url = String::from_utf8(
self.dir
.content(vpath)
.ok_or_else(|| RenderError::new("file does not exist"))?,
)
.map_err(|_| RenderError::new("included file does not contain UTF-8 text"))?;
Ok(ScopedJson::Derived(Value::String(url)))
} else {
Err(RenderError::new("missing path string"))
}
}
}

View file

@ -3,12 +3,13 @@ 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>(pub &'a str); pub struct EscapeAttribute<'a>(&'a str);
impl Display for EscapeAttribute<'_> { impl<'a> Display for EscapeAttribute<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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 == '"' {
@ -21,9 +22,9 @@ impl Display for EscapeAttribute<'_> {
} }
} }
pub struct EscapeHtml<'a>(pub &'a str); pub struct EscapeHtml<'a>(&'a str);
impl Display for EscapeHtml<'_> { impl<'a> Display for EscapeHtml<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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,25 +17,22 @@ use jotdown::OrderedListNumbering::*;
use jotdown::SpanLinkType; use jotdown::SpanLinkType;
use crate::config::Config; use crate::config::Config;
use crate::dirs::Dirs; use crate::config::ConfigDerivedData;
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 dirs: &'a Dirs, pub treehouse: &'a mut Treehouse,
pub treehouse: &'a Treehouse,
pub file_id: FileId, pub file_id: FileId,
pub page_id: String, pub page_id: String,
} }
impl Renderer<'_> { impl<'a> Renderer<'a> {
#[must_use] #[must_use]
pub fn render( pub fn render(
self, self,
@ -372,18 +369,25 @@ impl<'a> Writer<'a> {
r#"<img class="placeholder-image" loading="lazy" src=""#, r#"<img class="placeholder-image" loading="lazy" src=""#,
); );
let pic_url = self let filename = self.renderer.config.pics.get(placeholder_pic_id);
.renderer let pic_url = filename
.config .and_then(|filename| {
.pic_url(&*self.renderer.dirs.pic, placeholder_pic_id); self.renderer
.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('"');
if let Some(image_size) = self let image_size = filename.and_then(|filename| {
.renderer self.renderer
.config .config_derived_data
.pic_size(&*self.renderer.dirs.pic, placeholder_pic_id) .image_size(&format!("static/pic/{filename}"))
{ });
if let Some(image_size) = image_size {
write!( write!(
out, out,
r#" width="{}" height="{}""#, r#" width="{}" height="{}""#,
@ -478,7 +482,6 @@ 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 {
@ -520,7 +523,8 @@ 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 {
highlight(out, syntax, s); // TODO djot: make highlight infallible
highlight(out, syntax, s).map_err(|_| std::fmt::Error)?;
} else { } else {
write_text(s, out); write_text(s, out);
} }
@ -543,7 +547,7 @@ impl<'a> Writer<'a> {
}); });
} }
Event::Symbol(sym) => { Event::Symbol(sym) => {
if let Some(vpath) = self.renderer.config.emoji.get(sym.as_ref()) { if let Some(filename) = self.renderer.config.emoji.get(sym.as_ref()) {
let branch_id = self let branch_id = self
.renderer .renderer
.treehouse .treehouse
@ -561,12 +565,12 @@ impl<'a> Writer<'a> {
out.push_str(r#"">"#) out.push_str(r#"">"#)
} }
let url = vfs::url( let url = self
&self.renderer.config.site, .renderer
&*self.renderer.dirs.emoji, .config_derived_data
vpath, .static_urls
) .get(&format!("emoji/{filename}"))
.expect("emoji directory is not anchored anywhere"); .unwrap_or_default();
// TODO: this could do with better alt text // TODO: this could do with better alt text
write!( write!(
@ -576,7 +580,11 @@ impl<'a> Writer<'a> {
write_attr(&url, out); write_attr(&url, out);
out.push('"'); out.push('"');
if let Some(image_size) = self.renderer.dirs.emoji.image_size(vpath) { if let Some(image_size) = self
.renderer
.config_derived_data
.image_size(&format!("static/emoji/{filename}"))
{
write!( write!(
out, out,
r#" width="{}" height="{}""#, r#" width="{}" height="{}""#,
@ -627,7 +635,10 @@ 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, treehouse, .. config,
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(),
@ -642,7 +653,12 @@ impl<'a> Writer<'a> {
) )
}), }),
"page" => Some(config.page_url(linked)), "page" => Some(config.page_url(linked)),
"pic" => Some(config.pic_url(&*self.renderer.dirs.pic, linked)), "pic" => config.pics.get(linked).and_then(|filename| {
config_derived_data
.static_urls
.get(&format!("pic/{filename}"))
.ok()
}),
_ => None, _ => None,
}) })
} }

View file

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

View file

@ -0,0 +1,716 @@
// NOTE: This code is pasted pretty much verbatim from pulldown-cmark but tweaked to have my own
// cool additions.
// Copyright 2015 Google Inc. All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! HTML renderer that takes an iterator of events as input.
use std::collections::HashMap;
use std::io;
use pulldown_cmark::escape::{escape_href, escape_html, StrWrite};
use pulldown_cmark::{Alignment, CodeBlockKind, Event, LinkType, Tag};
use pulldown_cmark::{CowStr, Event::*};
use crate::config::{Config, ConfigDerivedData, ImageSize};
use crate::html::highlight::highlight;
use crate::state::Treehouse;
enum TableState {
Head,
Body,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CodeBlockState<'a> {
NotInCodeBlock,
InCodeBlock(Option<CowStr<'a>>),
}
struct HtmlWriter<'a, I, W> {
treehouse: &'a Treehouse,
config: &'a Config,
config_derived_data: &'a mut ConfigDerivedData,
page_id: &'a str,
/// Iterator supplying events.
iter: I,
/// Writer to write to.
writer: W,
/// Whether or not the last write wrote a newline.
end_newline: bool,
table_state: TableState,
table_alignments: Vec<Alignment>,
table_cell_index: usize,
numbers: HashMap<CowStr<'a>, usize>,
code_block_state: CodeBlockState<'a>,
}
impl<'a, I, W> HtmlWriter<'a, I, W>
where
I: Iterator<Item = Event<'a>>,
W: StrWrite,
{
fn new(
treehouse: &'a Treehouse,
config: &'a Config,
config_derived_data: &'a mut ConfigDerivedData,
page_id: &'a str,
iter: I,
writer: W,
) -> Self {
Self {
treehouse,
config,
config_derived_data,
page_id,
iter,
writer,
end_newline: true,
table_state: TableState::Head,
table_alignments: vec![],
table_cell_index: 0,
numbers: HashMap::new(),
code_block_state: CodeBlockState::NotInCodeBlock,
}
}
/// Writes a new line.
fn write_newline(&mut self) -> io::Result<()> {
self.end_newline = true;
self.writer.write_str("\n")
}
/// Writes a buffer, and tracks whether or not a newline was written.
#[inline]
fn write(&mut self, s: &str) -> io::Result<()> {
self.writer.write_str(s)?;
if !s.is_empty() {
self.end_newline = s.ends_with('\n');
}
Ok(())
}
fn run(mut self) -> io::Result<()> {
while let Some(event) = self.iter.next() {
match event {
Start(tag) => {
self.start_tag(tag)?;
}
End(tag) => {
self.end_tag(tag)?;
}
Text(text) => {
self.run_text(&text)?;
self.end_newline = text.ends_with('\n');
}
Code(text) => {
self.write("<code>")?;
escape_html(&mut self.writer, &text)?;
self.write("</code>")?;
}
Html(html) => {
self.write(&html)?;
}
SoftBreak => {
self.write_newline()?;
}
HardBreak => {
self.write("<br />\n")?;
}
Rule => {
if self.end_newline {
self.write("<hr />\n")?;
} else {
self.write("\n<hr />\n")?;
}
}
FootnoteReference(name) => {
let len = self.numbers.len() + 1;
self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
escape_html(&mut self.writer, &name)?;
self.write("\">")?;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "{}", number)?;
self.write("</a></sup>")?;
}
TaskListMarker(true) => {
self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?;
}
TaskListMarker(false) => {
self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?;
}
}
}
Ok(())
}
/// Writes the start of an HTML tag.
fn start_tag(&mut self, tag: Tag<'a>) -> io::Result<()> {
match tag {
Tag::Paragraph => {
if self.end_newline {
self.write("<p>")
} else {
self.write("\n<p>")
}
}
Tag::Heading(level, id, classes) => {
if self.end_newline {
self.end_newline = false;
self.write("<")?;
} else {
self.write("\n<")?;
}
write!(&mut self.writer, "{}", level)?;
if let Some(id) = id {
self.write(" id=\"")?;
escape_html(&mut self.writer, id)?;
self.write("\"")?;
}
let mut classes = classes.iter();
if let Some(class) = classes.next() {
self.write(" class=\"")?;
escape_html(&mut self.writer, class)?;
for class in classes {
self.write(" ")?;
escape_html(&mut self.writer, class)?;
}
self.write("\"")?;
}
self.write(">")
}
Tag::Table(alignments) => {
self.table_alignments = alignments;
self.write("<table>")
}
Tag::TableHead => {
self.table_state = TableState::Head;
self.table_cell_index = 0;
self.write("<thead><tr>")
}
Tag::TableRow => {
self.table_cell_index = 0;
self.write("<tr>")
}
Tag::TableCell => {
match self.table_state {
TableState::Head => {
self.write("<th")?;
}
TableState::Body => {
self.write("<td")?;
}
}
match self.table_alignments.get(self.table_cell_index) {
Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
_ => self.write(">"),
}
}
Tag::BlockQuote => {
if self.end_newline {
self.write("<blockquote>\n")
} else {
self.write("\n<blockquote>\n")
}
}
Tag::CodeBlock(info) => {
self.code_block_state = CodeBlockState::InCodeBlock(None);
if !self.end_newline {
self.write_newline()?;
}
match info {
CodeBlockKind::Fenced(language) => {
self.code_block_state = CodeBlockState::InCodeBlock(Some(language.clone()));
match CodeBlockMode::parse(&language) {
CodeBlockMode::PlainText => self.write("<pre><code>"),
CodeBlockMode::SyntaxHighlightOnly { language } => {
self.write("<pre><code class=\"language-")?;
escape_html(&mut self.writer, language)?;
if self.config.syntaxes.contains_key(language) {
self.write(" th-syntax-highlighting")?;
}
self.write("\">")
}
CodeBlockMode::LiterateProgram {
language,
kind,
program_name,
} => {
self.write(match &kind {
LiterateCodeKind::Input => {
"<th-literate-program data-mode=\"input\" "
}
LiterateCodeKind::Output { .. } => {
"<th-literate-program data-mode=\"output\" "
}
})?;
self.write("data-program=\"")?;
escape_href(&mut self.writer, self.page_id)?;
self.write(":")?;
escape_html(&mut self.writer, program_name)?;
self.write("\" data-language=\"")?;
escape_html(&mut self.writer, language)?;
self.write("\" role=\"code\">")?;
if let LiterateCodeKind::Output { placeholder_pic_id } = kind {
if !placeholder_pic_id.is_empty() {
self.write("<img class=\"placeholder-image\" loading=\"lazy\" src=\"")?;
escape_html(
&mut self.writer,
&self.config.pic_url(placeholder_pic_id),
)?;
self.write("\"")?;
if let Some(ImageSize { width, height }) = self
.config_derived_data
.pic_size(self.config, placeholder_pic_id)
{
self.write(&format!(
" width=\"{width}\" height=\"{height}\""
))?;
}
self.write(">")?;
}
}
self.write("<pre class=\"placeholder-console\">")?;
Ok(())
}
}
}
CodeBlockKind::Indented => self.write("<pre><code>"),
}
}
Tag::List(Some(1)) => {
if self.end_newline {
self.write("<ol>\n")
} else {
self.write("\n<ol>\n")
}
}
Tag::List(Some(start)) => {
if self.end_newline {
self.write("<ol start=\"")?;
} else {
self.write("\n<ol start=\"")?;
}
write!(&mut self.writer, "{}", start)?;
self.write("\">\n")
}
Tag::List(None) => {
if self.end_newline {
self.write("<ul>\n")
} else {
self.write("\n<ul>\n")
}
}
Tag::Item => {
if self.end_newline {
self.write("<li>")
} else {
self.write("\n<li>")
}
}
Tag::Emphasis => self.write("<em>"),
Tag::Strong => self.write("<strong>"),
Tag::Strikethrough => self.write("<del>"),
Tag::Link(LinkType::Email, dest, title) => {
self.write("<a href=\"mailto:")?;
escape_href(&mut self.writer, &dest)?;
if !title.is_empty() {
self.write("\" title=\"")?;
escape_html(&mut self.writer, &title)?;
}
self.write("\">")
}
Tag::Link(_link_type, dest, title) => {
self.write("<a href=\"")?;
escape_href(&mut self.writer, &dest)?;
if !title.is_empty() {
self.write("\" title=\"")?;
escape_html(&mut self.writer, &title)?;
}
self.write("\">")
}
Tag::Image(_link_type, dest, title) => {
self.write("<img class=\"pic\" src=\"")?;
escape_href(&mut self.writer, &dest)?;
self.write("\" alt=\"")?;
self.raw_text()?;
if !title.is_empty() {
self.write("\" title=\"")?;
escape_html(&mut self.writer, &title)?;
}
self.write("\" />")
}
Tag::FootnoteDefinition(name) => {
if self.end_newline {
self.write("<div class=\"footnote-definition\" id=\"")?;
} else {
self.write("\n<div class=\"footnote-definition\" id=\"")?;
}
escape_html(&mut self.writer, &name)?;
self.write("\"><sup class=\"footnote-definition-label\">")?;
let len = self.numbers.len() + 1;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "{}", number)?;
self.write("</sup>")
}
}
}
fn end_tag(&mut self, tag: Tag) -> io::Result<()> {
match tag {
Tag::Paragraph => {
self.write("</p>\n")?;
}
Tag::Heading(level, _id, _classes) => {
self.write("</")?;
write!(&mut self.writer, "{}", level)?;
self.write(">\n")?;
}
Tag::Table(_) => {
self.write("</tbody></table>\n")?;
}
Tag::TableHead => {
self.write("</tr></thead><tbody>\n")?;
self.table_state = TableState::Body;
}
Tag::TableRow => {
self.write("</tr>\n")?;
}
Tag::TableCell => {
match self.table_state {
TableState::Head => {
self.write("</th>")?;
}
TableState::Body => {
self.write("</td>")?;
}
}
self.table_cell_index += 1;
}
Tag::BlockQuote => {
self.write("</blockquote>\n")?;
}
Tag::CodeBlock(kind) => {
self.write(match kind {
CodeBlockKind::Fenced(language) => match CodeBlockMode::parse(&language) {
CodeBlockMode::LiterateProgram { .. } => "</pre></th-literate-program>",
_ => "</code></pre>",
},
_ => "</code></pre>\n",
})?;
self.code_block_state = CodeBlockState::NotInCodeBlock;
}
Tag::List(Some(_)) => {
self.write("</ol>\n")?;
}
Tag::List(None) => {
self.write("</ul>\n")?;
}
Tag::Item => {
self.write("</li>\n")?;
}
Tag::Emphasis => {
self.write("</em>")?;
}
Tag::Strong => {
self.write("</strong>")?;
}
Tag::Strikethrough => {
self.write("</del>")?;
}
Tag::Link(_, _, _) => {
self.write("</a>")?;
}
Tag::Image(_, _, _) => (), // shouldn't happen, handled in start
Tag::FootnoteDefinition(_) => {
self.write("</div>\n")?;
}
}
Ok(())
}
fn run_text(&mut self, text: &str) -> io::Result<()> {
struct EmojiParser<'a> {
text: &'a str,
position: usize,
}
enum Token<'a> {
Text(&'a str),
Emoji(&'a str),
}
impl<'a> EmojiParser<'a> {
fn current(&self) -> Option<char> {
self.text[self.position..].chars().next()
}
fn next_token(&mut self) -> Option<Token<'a>> {
match self.current() {
Some(':') => {
let text_start = self.position;
self.position += 1;
if self.current().is_some_and(|c| c.is_alphabetic()) {
let name_start = self.position;
while let Some(c) = self.current() {
if c.is_alphanumeric() || c == '_' {
self.position += c.len_utf8();
} else {
break;
}
}
if self.current() == Some(':') {
let name_end = self.position;
self.position += 1;
Some(Token::Emoji(&self.text[name_start..name_end]))
} else {
Some(Token::Text(&self.text[text_start..self.position]))
}
} else {
Some(Token::Text(&self.text[text_start..self.position]))
}
}
Some(_) => {
let start = self.position;
while let Some(c) = self.current() {
if c == ':' {
break;
} else {
self.position += c.len_utf8();
}
}
let end = self.position;
Some(Token::Text(&self.text[start..end]))
}
None => None,
}
}
}
if let CodeBlockState::InCodeBlock(language) = &self.code_block_state {
let code_block_mode = language
.as_ref()
.map(|language| CodeBlockMode::parse(language));
let highlighting_language = code_block_mode
.as_ref()
.and_then(|mode| mode.highlighting_language());
let syntax =
highlighting_language.and_then(|language| self.config.syntaxes.get(language));
if let Some(syntax) = syntax {
highlight(&mut self.writer, syntax, text)?;
} else {
escape_html(&mut self.writer, text)?;
}
} else {
let mut parser = EmojiParser { text, position: 0 };
while let Some(token) = parser.next_token() {
match token {
Token::Text(text) => escape_html(&mut self.writer, text)?,
Token::Emoji(name) => {
if let Some(filename) = self.config.emoji.get(name) {
let branch_id = self
.treehouse
.branches_by_named_id
.get(&format!("emoji/{name}"))
.copied();
if let Some(branch) = branch_id.map(|id| self.treehouse.tree.branch(id))
{
self.writer.write_str("<a href=\"")?;
escape_html(&mut self.writer, &self.config.site)?;
self.writer.write_str("/b?")?;
escape_html(&mut self.writer, &branch.attributes.id)?;
self.writer.write_str("\">")?;
}
self.writer
.write_str("<img data-cast=\"emoji\" title=\":")?;
escape_html(&mut self.writer, name)?;
self.writer.write_str(":\" src=\"")?;
let url = self
.config_derived_data
.static_urls
.get(&format!("emoji/{filename}"))
.unwrap_or_default();
escape_html(&mut self.writer, &url)?;
self.writer.write_str("\" alt=\"")?;
escape_html(&mut self.writer, name)?;
if let Some(image_size) = self
.config_derived_data
.image_size(&format!("static/emoji/{filename}"))
{
write!(
self.writer,
"\" width=\"{}\" height=\"{}",
image_size.width, image_size.height
)?;
}
self.writer.write_str("\">")?;
if branch_id.is_some() {
self.writer.write_str("</a>")?;
}
} else {
self.writer.write_str(":")?;
escape_html(&mut self.writer, name)?;
self.writer.write_str(":")?;
}
}
}
}
}
Ok(())
}
// run raw text, consuming end tag
fn raw_text(&mut self) -> io::Result<()> {
let mut nest = 0;
while let Some(event) = self.iter.next() {
match event {
Start(_) => nest += 1,
End(_) => {
if nest == 0 {
break;
}
nest -= 1;
}
Html(text) | Code(text) | Text(text) => {
escape_html(&mut self.writer, &text)?;
self.end_newline = text.ends_with('\n');
}
SoftBreak | HardBreak | Rule => {
self.write(" ")?;
}
FootnoteReference(name) => {
let len = self.numbers.len() + 1;
let number = *self.numbers.entry(name).or_insert(len);
write!(&mut self.writer, "[{}]", number)?;
}
TaskListMarker(true) => self.write("[x]")?,
TaskListMarker(false) => self.write("[ ]")?,
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LiterateCodeKind<'a> {
Input,
Output { placeholder_pic_id: &'a str },
}
enum CodeBlockMode<'a> {
PlainText,
SyntaxHighlightOnly {
language: &'a str,
},
LiterateProgram {
language: &'a str,
kind: LiterateCodeKind<'a>,
program_name: &'a str,
},
}
impl<'a> CodeBlockMode<'a> {
fn parse(language: &'a str) -> CodeBlockMode<'a> {
if language.is_empty() {
CodeBlockMode::PlainText
} else if let Some((language, program_name)) = language.split_once(' ') {
let (program_name, placeholder_pic_id) =
program_name.split_once(' ').unwrap_or((program_name, ""));
CodeBlockMode::LiterateProgram {
language,
kind: if language == "output" {
LiterateCodeKind::Output { placeholder_pic_id }
} else {
LiterateCodeKind::Input
},
program_name: program_name.split(' ').next().unwrap(),
}
} else {
CodeBlockMode::SyntaxHighlightOnly { language }
}
}
fn highlighting_language(&self) -> Option<&str> {
if let CodeBlockMode::LiterateProgram { language, .. }
| CodeBlockMode::SyntaxHighlightOnly { language } = self
{
Some(language)
} else {
None
}
}
}
/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
/// push it to a `String`.
///
/// # Examples
///
/// ```
/// use pulldown_cmark::{html, Parser};
///
/// let markdown_str = r#"
/// hello
/// =====
///
/// * alpha
/// * beta
/// "#;
/// let parser = Parser::new(markdown_str);
///
/// let mut html_buf = String::new();
/// html::push_html(&mut html_buf, parser);
///
/// assert_eq!(html_buf, r#"<h1>hello</h1>
/// <ul>
/// <li>alpha</li>
/// <li>beta</li>
/// </ul>
/// "#);
/// ```
pub fn push_html<'a, I>(
s: &mut String,
treehouse: &'a Treehouse,
config: &'a Config,
config_derived_data: &'a mut ConfigDerivedData,
page_id: &'a str,
iter: I,
) where
I: Iterator<Item = Event<'a>>,
{
HtmlWriter::new(treehouse, config, config_derived_data, page_id, iter, s)
.run()
.unwrap();
}

View file

@ -1,72 +1,82 @@
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)] #[derive(Debug, Clone, Default, Serialize)]
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 build(treehouse: &Treehouse, root_tree_path: &str) -> Self { pub fn to_javascript(&self) -> String {
let mut builder = NavigationMapBuilder::default(); format!(
"export const navigationMap = {};",
fn rec_branch( serde_json::to_string(&self.paths)
treehouse: &Treehouse, .expect("serialization of the navigation map should not fail")
builder: &mut NavigationMapBuilder, )
branch_id: SemaBranchId,
) {
let branch = treehouse.tree.branch(branch_id);
if let Content::Link(linked) = &branch.attributes.content {
rec_tree(treehouse, builder, linked);
} else {
for &child_id in &branch.children {
rec_branch(treehouse, builder, child_id);
}
}
}
fn rec_tree(treehouse: &Treehouse, builder: &mut NavigationMapBuilder, tree_path: &str) {
if let Some(roots) = treehouse.roots.get(tree_path) {
// Pages can link to each other causing infinite recursion, so we need to handle that
// case by skipping pages that already have been analyzed.
if !builder.navigation_map.paths.contains_key(tree_path) {
builder.enter_tree(tree_path.to_owned());
for &branch_id in &roots.branches {
rec_branch(treehouse, builder, branch_id);
}
builder.exit_tree();
}
}
}
rec_tree(treehouse, &mut builder, root_tree_path);
builder.finish()
} }
} }
#[derive(Debug, Clone, Default)]
pub struct NavigationMapBuilder {
stack: Vec<String>,
navigation_map: NavigationMap,
}
impl NavigationMapBuilder {
pub fn enter_tree(&mut self, tree: String) {
self.stack.push(tree.clone());
self.navigation_map.paths.insert(tree, self.stack.clone());
}
pub fn exit_tree(&mut self) {
self.stack.pop();
}
pub fn finish(self) -> NavigationMap {
self.navigation_map
}
}
pub fn build_navigation_map(treehouse: &Treehouse, root_tree_path: &str) -> NavigationMap {
let mut builder = NavigationMapBuilder::default();
fn rec_branch(
treehouse: &Treehouse,
builder: &mut NavigationMapBuilder,
branch_id: SemaBranchId,
) {
let branch = treehouse.tree.branch(branch_id);
if let Content::Link(linked) = &branch.attributes.content {
rec_tree(treehouse, builder, linked);
} else {
for &child_id in &branch.children {
rec_branch(treehouse, builder, child_id);
}
}
}
fn rec_tree(treehouse: &Treehouse, builder: &mut NavigationMapBuilder, tree_path: &str) {
if let Some(roots) = treehouse.roots.get(tree_path) {
// Pages can link to each other causing infinite recursion, so we need to handle that
// case by skipping pages that already have been analyzed.
if !builder.navigation_map.paths.contains_key(tree_path) {
builder.enter_tree(tree_path.to_owned());
for &branch_id in &roots.branches {
rec_branch(treehouse, builder, branch_id);
}
builder.exit_tree();
}
}
}
rec_tree(treehouse, &mut builder, root_tree_path);
builder.finish()
}

View file

@ -1,10 +1,11 @@
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::{
config::Config, cli::Paths,
dirs::Dirs, config::{Config, ConfigDerivedData, Markup},
html::EscapeAttribute, html::EscapeAttribute,
state::{FileId, Treehouse}, state::{FileId, Treehouse},
tree::{ tree::{
@ -13,13 +14,14 @@ use crate::{
}, },
}; };
use super::{djot, EscapeHtml}; use super::{djot, markdown, EscapeHtml};
pub fn branch_to_html( pub fn branch_to_html(
s: &mut String, s: &mut String,
treehouse: &Treehouse, treehouse: &mut Treehouse,
config: &Config, config: &Config,
dirs: &Dirs, config_derived_data: &mut ConfigDerivedData,
paths: &Paths<'_>,
file_id: FileId, file_id: FileId,
branch_id: SemaBranchId, branch_id: SemaBranchId,
) { ) {
@ -113,28 +115,87 @@ 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, dirs, &final_markup); final_markup = mini_template::render(config, treehouse, paths, &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(),
let events: Vec<_> = jotdown::Parser::new(&final_markup) config,
.into_offset_iter() config_derived_data,
.collect(); treehouse,
// TODO: Report rendering diagnostics. file_id,
let render_diagnostics = djot::Renderer { }
page_id: treehouse .render(&events, s);
.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 {
@ -186,7 +247,15 @@ 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(s, treehouse, config, dirs, file_id, child_id); branch_to_html(
s,
treehouse,
config,
config_derived_data,
paths,
file_id,
child_id,
);
} }
s.push_str("</ul>"); s.push_str("</ul>");
} }
@ -200,15 +269,24 @@ pub fn branch_to_html(
pub fn branches_to_html( pub fn branches_to_html(
s: &mut String, s: &mut String,
treehouse: &Treehouse, treehouse: &mut Treehouse,
config: &Config, config: &Config,
dirs: &Dirs, config_derived_data: &mut ConfigDerivedData,
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(s, treehouse, config, dirs, file_id, child); branch_to_html(
s,
treehouse,
config,
config_derived_data,
paths,
file_id,
child,
);
} }
s.push_str("</ul>"); s.push_str("</ul>");
} }

View file

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

View file

@ -0,0 +1,28 @@
use std::path::PathBuf;
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
use serde_json::Value;
pub struct IncludeStatic {
pub base_dir: PathBuf,
}
impl HelperDef for IncludeStatic {
fn call_inner<'reg: 'rc, 'rc>(
&self,
helper: &Helper<'reg, 'rc>,
_: &'reg Handlebars<'reg>,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
if let Some(param) = helper.param(0).and_then(|v| v.value().as_str()) {
return Ok(ScopedJson::Derived(Value::String(
std::fs::read_to_string(self.base_dir.join(param)).map_err(|error| {
RenderError::new(format!("cannot read static asset {param}: {error}"))
})?,
)));
}
Err(RenderError::new("asset path must be provided"))
}
}

View file

@ -1,13 +0,0 @@
pub mod cli;
pub mod config;
pub mod dirs;
pub mod fun;
pub mod generate;
pub mod history;
pub mod html;
pub mod import_map;
pub mod parse;
pub mod paths;
pub mod state;
pub mod tree;
pub mod vfs;

View file

@ -1,83 +1,71 @@
use std::fs; use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
use clap::Parser; use clap::Parser;
use log::error; use cli::{
use treehouse::cli::serve::serve; fix::{fix_all_cli, fix_file_cli},
use treehouse::dirs::Dirs; serve::serve,
use treehouse::generate::{self, Sources}; wc::wc_cli,
use treehouse::vfs::asynch::AsyncDir; Command, Paths, ProgramArgs,
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};
fn vfs_sources() -> anyhow::Result<DynDir> { mod cli;
let mut root = MemDir::new(); mod config;
mod fun;
root.add( mod generate;
VPath::new("treehouse.toml"), mod history;
BufferedFile::new(fs::read("treehouse.toml")?).to_dyn(), mod html;
); mod import_map;
root.add( mod include_static;
VPath::new("static"), mod parse;
PhysicalDir::new(PathBuf::from("static")) mod paths;
.anchored_at(VPathBuf::new("static")) mod state;
.to_dyn(), mod static_urls;
); 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 src = vfs_sources()?; let paths = Paths {
let dirs = Arc::new(Dirs { target_dir: Path::new("target/site"),
root: src.clone(), template_target_dir: Path::new("target/site/static/html"),
content: Cd::new(src.clone(), VPathBuf::new("content")).to_dyn(),
static_: Cd::new(src.clone(), VPathBuf::new("static")).to_dyn(), config_file: Path::new("treehouse.toml"),
template: Cd::new(src.clone(), VPathBuf::new("template")).to_dyn(),
pic: Cd::new(src.clone(), VPathBuf::new("static/pic")).to_dyn(), // NOTE: These are intentionally left unconfigurable from within treehouse.toml
emoji: Cd::new(src.clone(), VPathBuf::new("static/emoji")).to_dyn(), // because this is is one of those things that should be consistent between sites.
syntax: Cd::new(src.clone(), VPathBuf::new("static/syntax")).to_dyn(), static_dir: Path::new("static"),
}); 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: generate_args,
serve: serve_args, serve: serve_args,
} => { } => {
let sources = Arc::new(Sources::load(&dirs).context("failed to load sources")?); let latest_revision = match generate_args.commits_only {
let target = generate::target(dirs, sources.clone()); true => LatestRevision::LatestCommit,
serve(sources, AsyncDir::new(target), serve_args.port).await?; false => LatestRevision::WorkingTree,
};
let (config, treehouse) = regenerate_or_report_error(&paths, latest_revision)?;
serve(config, treehouse, &paths, serve_args.port).await?;
} }
Command::Fix(fix_args) => fix_file_cli(fix_args, &*dirs.content)?.apply().await?, Command::Fix(fix_args) => fix_file_cli(fix_args)?,
Command::FixAll(fix_args) => fix_all_cli(fix_args, &*dirs.content)?.apply().await?, Command::FixAll(fix_args) => fix_all_cli(fix_args, &paths)?,
Command::Wc(wc_args) => wc_cli(&dirs.content, wc_args)?, Command::Wc(wc_args) => wc_cli(paths.content_dir, wc_args)?,
Command::Ulid => { 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}; use std::{collections::HashMap, ops::Range, path::PathBuf};
use anyhow::Context; use anyhow::Context;
use codespan_reporting::{ use codespan_reporting::{
@ -6,19 +6,26 @@ 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::{ use crate::tree::{SemaBranchId, SemaRoots, SemaTree};
tree::{SemaBranchId, SemaRoots, SemaTree},
vfs::VPathBuf, #[derive(Debug, Clone, Serialize)]
}; 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: VPathBuf, target_path: PathBuf,
revision_info: RevisionInfo,
}, },
Other(String), Other(String),
} }
@ -96,6 +103,13 @@ impl Treehouse {
} }
} }
pub fn revision_info(&self, file_id: FileId) -> Option<&RevisionInfo> {
match self.source(file_id) {
Source::Tree { revision_info, .. } => Some(revision_info),
Source::Other(_) => None,
}
}
pub fn next_missingno(&mut self) -> Ulid { pub fn next_missingno(&mut self) -> Ulid {
self.missingno_generator self.missingno_generator
.generate() .generate()
@ -103,12 +117,6 @@ 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

@ -0,0 +1,89 @@
use std::{
collections::HashMap,
fs::File,
io::{self, BufReader},
path::PathBuf,
sync::{Mutex, RwLock},
};
use handlebars::{Context, Handlebars, Helper, HelperDef, RenderContext, RenderError, ScopedJson};
use serde_json::Value;
pub struct StaticUrls {
base_dir: PathBuf,
base_url: String,
// Really annoying that we have to use an RwLock for this. We only ever generate in a
// single-threaded environment.
// Honestly it would be a lot more efficient if Handlebars just assumed single-threadedness
// and required you to clone it over to different threads.
// Stuff like this is why I really want to implement my own templating engine...
hash_cache: RwLock<HashMap<String, String>>,
missing_files: Mutex<Vec<MissingFile>>,
}
pub struct MissingFile {
pub path: String,
}
impl StaticUrls {
pub fn new(base_dir: PathBuf, base_url: String) -> Self {
Self {
base_dir,
base_url,
hash_cache: RwLock::new(HashMap::new()),
missing_files: Mutex::new(vec![]),
}
}
pub fn get(&self, filename: &str) -> Result<String, io::Error> {
let hash_cache = self.hash_cache.read().unwrap();
if let Some(cached) = hash_cache.get(filename) {
return Ok(cached.to_owned());
}
drop(hash_cache);
let mut hasher = blake3::Hasher::new();
let file = BufReader::new(File::open(self.base_dir.join(filename))?);
hasher.update_reader(file)?;
// NOTE: Here the hash is truncated to 8 characters. This is fine, because we don't
// care about security here - only detecting changes in files.
let hash = format!(
"{}/{}?cache=b3-{}",
self.base_url,
filename,
&hasher.finalize().to_hex()[0..8]
);
{
let mut hash_cache = self.hash_cache.write().unwrap();
hash_cache.insert(filename.to_owned(), hash.clone());
}
Ok(hash)
}
pub fn take_missing_files(&self) -> Vec<MissingFile> {
std::mem::take(&mut self.missing_files.lock().unwrap())
}
}
impl HelperDef for StaticUrls {
fn call_inner<'reg: 'rc, 'rc>(
&self,
helper: &Helper<'reg, 'rc>,
_: &'reg Handlebars<'reg>,
_: &'rc Context,
_: &mut RenderContext<'reg, 'rc>,
) -> Result<ScopedJson<'reg, 'rc>, RenderError> {
if let Some(param) = helper.param(0).and_then(|v| v.value().as_str()) {
return Ok(ScopedJson::Derived(Value::String(
self.get(param).unwrap_or_else(|_| {
self.missing_files.lock().unwrap().push(MissingFile {
path: param.to_owned(),
});
format!("{}/{}", self.base_url, param)
}),
)));
}
Err(RenderError::new("asset path must be provided"))
}
}

View file

@ -163,7 +163,13 @@ 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 named_id = attributes.id.to_owned(); let revision_info = treehouse
.revision_info(file_id)
.expect(".tree files must have Tree-type sources");
let named_id = match revision_info.is_latest {
true => attributes.id.to_owned(),
false => format!("{}@{}", attributes.id, revision_info.commit_short),
};
let html_id = format!( let html_id = format!(
"{}:{}", "{}:{}",
treehouse.tree_path(file_id).unwrap(), treehouse.tree_path(file_id).unwrap(),

View file

@ -4,16 +4,11 @@
//! 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 crate::{ use pulldown_cmark::escape::escape_html;
config::Config,
dirs::Dirs, use crate::{cli::Paths, config::Config, state::Treehouse};
html::EscapeHtml,
state::Treehouse,
vfs::{Dir, VPath},
};
struct Lexer<'a> { struct Lexer<'a> {
input: &'a str, input: &'a str,
@ -149,12 +144,12 @@ struct Renderer<'a> {
struct InvalidTemplate; struct InvalidTemplate;
impl Renderer<'_> { impl<'a> Renderer<'a> {
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, dirs: &Dirs) { fn render(&mut self, config: &Config, treehouse: &Treehouse, paths: &Paths<'_>) {
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() {
@ -171,12 +166,12 @@ impl Renderer<'_> {
match Self::render_template( match Self::render_template(
config, config,
treehouse, treehouse,
dirs, paths,
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 => {
_ = write!(self.output, "{}", EscapeHtml(&s)); _ = escape_html(&mut self.output, &s);
} }
EscapingMode::NoEscaping => self.output.push_str(&s), EscapingMode::NoEscaping => self.output.push_str(&s),
}, },
@ -198,27 +193,24 @@ impl Renderer<'_> {
fn render_template( fn render_template(
config: &Config, config: &Config,
_treehouse: &Treehouse, _treehouse: &Treehouse,
dirs: &Dirs, paths: &Paths<'_>,
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(&*dirs.pic, arguments)), "pic" => Ok(config.pic_url(arguments)),
"include_static" => VPath::try_new(arguments) "include_static" => std::fs::read_to_string(paths.static_dir.join(arguments))
.ok() .map_err(|_| InvalidTemplate),
.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, dirs: &Dirs, input: &str) -> String { pub fn render(config: &Config, treehouse: &Treehouse, paths: &Paths<'_>, 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, dirs); renderer.render(config, treehouse, paths);
renderer.output renderer.output
} }

View file

@ -1,247 +0,0 @@
//! The treehouse virtual file system.
//!
//! Unlike traditional file systems, there is no separation between directories and files.
//! Instead, our file system is based on _entries_, which may have specific, optional, well-typed
//! metadata attached to them.
//! A directory is formed by returning a list of paths from [`dir`][Dir::dir], and a file is
//! formed by returning `Some` from [`content`][Dir::content].
//!
//! This makes using the file system simpler, as you do not have to differentiate between different
//! entry kinds. All paths act as if they _could_ return byte content, and all paths act as if they
//! _could_ have children.
//!
//! # Composability
//!
//! [`Dir`]s are composable. The [`Dir`] itself starts off with the root path ([`VPath::ROOT`]),
//! which may contain further [`dir`][Dir::dir] entries, or content by itself.
//! This makes it possible to nest a [`Dir`] under another [`Dir`].
//!
//! Additionally, there's also the inverse operation, [`Cd`] (named after the `cd`
//! _change directory_ shell command), which returns a [`Dir`] viewing a subpath within another
//! [`Dir`].
//!
//! # Building directories
//!
//! In-memory directories can be composed using the following primitives:
//!
//! - [`EmptyEntry`] - has no metadata whatsoever.
//! - [`BufferedFile`] - root path content is the provided byte vector.
//! - [`MemDir`] - a [`Dir`] containing a single level of other [`Dir`]s inside.
//!
//! Additionally, for interfacing with the OS file system, [`PhysicalDir`] is available,
//! representing a directory stored on the disk.
//!
//! # Virtual paths
//!
//! Entries within directories are referenced using [`VPath`]s (**v**irtual **path**s).
//! A virtual path is composed out of any amount of `/`-separated components.
//!
//! There are no special directories like `.` and `..` (those are just normal entries, though using
//! them is discouraged). [`VPath`]s are always relative to the root of the [`Dir`] you're querying.
//!
//! A leading or trailing slash is not allowed, because they would have no meaning.
//!
//! [`VPath`] also has an owned version, [`VPathBuf`].
use std::{
fmt::{self, Debug},
ops::{ControlFlow, Deref},
sync::Arc,
};
mod anchored;
pub mod asynch;
mod cd;
mod content_cache;
mod content_version_cache;
mod edit;
mod empty;
mod file;
mod image_size_cache;
mod mem_dir;
mod overlay;
mod path;
mod physical;
pub use anchored::*;
pub use cd::*;
pub use content_cache::*;
pub use content_version_cache::*;
pub use edit::*;
pub use empty::*;
pub use file::*;
pub use image_size_cache::*;
pub use mem_dir::*;
pub use overlay::*;
pub use path::*;
pub use physical::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct DirEntry {
pub path: VPathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ImageSize {
pub width: u32,
pub height: u32,
}
pub trait Dir: Debug {
/// List all entries under the provided path.
fn dir(&self, path: &VPath) -> Vec<DirEntry>;
/// Return the byte content of the entry at the given path.
fn content(&self, path: &VPath) -> Option<Vec<u8>>;
/// Get a string signifying the current version of the provided path's content.
/// If the content changes, the version must also change.
///
/// Returns None if there is no content or no version string is available.
fn content_version(&self, path: &VPath) -> Option<String>;
/// Returns the size of the image at the given path, or `None` if the entry is not an image
/// (or its size cannot be known.)
fn image_size(&self, _path: &VPath) -> Option<ImageSize> {
None
}
/// Returns a path relative to `config.site` indicating where the file will be available
/// once served.
///
/// May return `None` if the file is not served.
fn anchor(&self, _path: &VPath) -> Option<VPathBuf> {
None
}
/// If a file can be written persistently, returns an [`EditPath`] representing the file in
/// persistent storage.
///
/// An edit path can then be made into an [`Edit`].
fn edit_path(&self, _path: &VPath) -> Option<EditPath> {
None
}
}
impl<T> Dir for &T
where
T: Dir,
{
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
(**self).dir(path)
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
(**self).content(path)
}
fn content_version(&self, path: &VPath) -> Option<String> {
(**self).content_version(path)
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
(**self).image_size(path)
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
(**self).anchor(path)
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
(**self).edit_path(path)
}
}
#[derive(Clone)]
pub struct DynDir {
arc: Arc<dyn Dir + Send + Sync>,
}
impl Dir for DynDir {
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
self.arc.dir(path)
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
self.arc.content(path)
}
fn content_version(&self, path: &VPath) -> Option<String> {
self.arc.content_version(path)
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
self.arc.image_size(path)
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
self.arc.anchor(path)
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
self.arc.edit_path(path)
}
}
impl fmt::Debug for DynDir {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&*self.arc, f)
}
}
impl Deref for DynDir {
type Target = dyn Dir + Send + Sync;
fn deref(&self) -> &Self::Target {
&*self.arc
}
}
pub trait ToDynDir {
fn to_dyn(self) -> DynDir;
}
impl<T> ToDynDir for T
where
T: Dir + Send + Sync + 'static,
{
fn to_dyn(self) -> DynDir {
DynDir {
arc: Arc::new(self),
}
}
}
pub trait AnchoredAtExt {
fn anchored_at(self, at: VPathBuf) -> Anchored<Self>
where
Self: Sized;
}
impl<T> AnchoredAtExt for T
where
T: Dir,
{
fn anchored_at(self, at: VPathBuf) -> Anchored<Self> {
Anchored::new(self, at)
}
}
pub fn walk_dir_rec(dir: &dyn Dir, path: &VPath, f: &mut dyn FnMut(&VPath) -> ControlFlow<(), ()>) {
for entry in dir.dir(path) {
match f(&entry.path) {
ControlFlow::Continue(_) => (),
ControlFlow::Break(_) => return,
}
walk_dir_rec(dir, &entry.path, f);
}
}
pub fn url(site: &str, dir: &dyn Dir, path: &VPath) -> Option<String> {
let anchor = dir.anchor(path)?;
if let Some(version) = dir.content_version(path) {
Some(format!("{}/{anchor}?v={version}", site))
} else {
Some(format!("{}/{anchor}", site))
}
}

View file

@ -1,52 +0,0 @@
use std::fmt;
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
pub struct Anchored<T> {
inner: T,
at: VPathBuf,
}
impl<T> Anchored<T> {
pub fn new(inner: T, at: VPathBuf) -> Self {
Self { inner, at }
}
}
impl<T> Dir for Anchored<T>
where
T: Dir,
{
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
self.inner.dir(path)
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
self.inner.content(path)
}
fn content_version(&self, path: &VPath) -> Option<String> {
self.inner.content_version(path)
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
self.inner.image_size(path)
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
Some(self.at.join(path))
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
self.inner.edit_path(path)
}
}
impl<T> fmt::Debug for Anchored<T>
where
T: fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Anchored({:?}, {})", self.inner, self.at)
}
}

View file

@ -1,23 +0,0 @@
use super::{Dir, DynDir, VPath};
#[derive(Debug, Clone)]
pub struct AsyncDir {
inner: DynDir,
}
impl AsyncDir {
pub fn new(inner: DynDir) -> Self {
Self { inner }
}
pub async fn content(&self, path: &VPath) -> Option<Vec<u8>> {
let this = self.clone();
let path = path.to_owned();
// NOTE: Performance impact of spawning a blocking task may be a bit high in case
// we add caching.
// Measure throughput here.
tokio::task::spawn_blocking(move || this.inner.content(&path))
.await
.unwrap()
}
}

View file

@ -1,62 +0,0 @@
use std::fmt;
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
pub struct Cd<T> {
parent: T,
path: VPathBuf,
}
impl<T> Cd<T> {
pub fn new(parent: T, path: VPathBuf) -> Self {
Self { parent, path }
}
}
impl<T> Dir for Cd<T>
where
T: Dir,
{
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
self.parent
.dir(&self.path.join(path))
.into_iter()
.map(|entry| DirEntry {
path: entry
.path
.strip_prefix(&self.path)
.expect("all entries must be anchored within `self.path`")
.to_owned(),
})
.collect()
}
fn content_version(&self, path: &VPath) -> Option<String> {
self.parent.content_version(&self.path.join(path))
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
self.parent.content(&self.path.join(path))
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
self.parent.image_size(&self.path.join(path))
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
self.parent.anchor(&self.path.join(path))
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
self.parent.edit_path(&self.path.join(path))
}
}
impl<T> fmt::Debug for Cd<T>
where
T: fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}/{:?}", self.parent, self.path)
}
}

View file

@ -1,81 +0,0 @@
use std::{
fmt::{self, Debug},
ops::ControlFlow,
};
use dashmap::DashMap;
use log::debug;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use super::{walk_dir_rec, Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
pub struct ContentCache<T> {
inner: T,
cache: DashMap<VPathBuf, Option<Vec<u8>>>,
}
impl<T> ContentCache<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
cache: DashMap::new(),
}
}
}
impl<T> ContentCache<T>
where
T: Dir + Send + Sync,
{
pub fn warm_up(&self) {
debug!("warm_up({self:?})");
let mut paths = vec![];
walk_dir_rec(&self.inner, VPath::ROOT, &mut |path| {
paths.push(path.to_owned());
ControlFlow::Continue(())
});
paths.par_iter().for_each(|path| _ = self.content(path));
}
}
impl<T> Dir for ContentCache<T>
where
T: Dir,
{
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
self.inner.dir(path)
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
self.cache
.entry(path.to_owned())
.or_insert_with(|| self.inner.content(path))
.clone()
}
fn content_version(&self, path: &VPath) -> Option<String> {
self.inner.content_version(path)
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
self.inner.image_size(path)
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
self.inner.anchor(path)
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
self.inner.edit_path(path)
}
}
impl<T> fmt::Debug for ContentCache<T>
where
T: Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ContentCache({:?})", self.inner)
}
}

View file

@ -1,65 +0,0 @@
use std::fmt::{self, Debug};
use dashmap::DashMap;
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
pub struct Blake3ContentVersionCache<T> {
inner: T,
cache: DashMap<VPathBuf, Option<String>>,
}
impl<T> Blake3ContentVersionCache<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
cache: DashMap::new(),
}
}
}
impl<T> Dir for Blake3ContentVersionCache<T>
where
T: Dir,
{
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
self.inner.dir(path)
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
self.inner.content(path)
}
fn content_version(&self, path: &VPath) -> Option<String> {
self.cache
.entry(path.to_owned())
.or_insert_with(|| {
self.content(path).map(|content| {
let hash = blake3::hash(&content).to_hex();
format!("b3-{}", &hash[0..8])
})
})
.clone()
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
self.inner.image_size(path)
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
self.inner.anchor(path)
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
self.inner.edit_path(path)
}
}
impl<T> fmt::Debug for Blake3ContentVersionCache<T>
where
T: Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Blake3ContentVersionCache({:?})", self.inner)
}
}

View file

@ -1,92 +0,0 @@
use std::{error::Error, fmt, future::Future, path::PathBuf};
use log::{error, info};
use tokio::task::JoinSet;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct EditPath {
pub(super) path: PathBuf,
}
/// Represents a pending edit operation that can be written to persistent storage later.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Edit {
/// An edit that doesn't do anything.
NoOp,
/// Write the given string to a file.
Write(EditPath, String),
/// Execute a sequence of edits in order.
Seq(Vec<Edit>),
/// Execute the provided edits in parallel.
All(Vec<Edit>),
/// Makes an edit dry.
///
/// A dry edit only logs what operations would be performed, does not perform the I/O.
Dry(Box<Edit>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ApplyFailed;
impl Edit {
#[expect(clippy::manual_async_fn)]
pub fn apply(self) -> impl Future<Output = Result<(), ApplyFailed>> + Send {
async {
match self {
Edit::NoOp => (),
Edit::Write(edit_path, content) => {
tokio::fs::write(&edit_path.path, &content)
.await
.inspect_err(|err| error!("write to {edit_path:?} failed: {err:?}"))
.map_err(|_| ApplyFailed)?;
}
Edit::Seq(vec) => {
for edit in vec {
Box::pin(edit.apply()).await?;
}
}
Edit::All(vec) => {
let mut set = JoinSet::new();
for edit in vec {
set.spawn(edit.apply());
}
while let Some(result) = set.try_join_next() {
result.map_err(|_| ApplyFailed)??;
}
}
Edit::Dry(edit) => edit.dry(),
}
Ok(())
}
}
pub fn dry(&self) {
match self {
Edit::NoOp => (),
Edit::Write(edit_path, content) => {
info!("{edit_path:?}: would write {:?} bytes", content.len());
}
Edit::Seq(edits) => edits.iter().for_each(Self::dry),
Edit::All(edits) => edits.iter().for_each(Self::dry),
Edit::Dry(edit) => edit.dry(),
}
}
}
impl fmt::Display for ApplyFailed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("failed to apply some edits")
}
}
impl Error for ApplyFailed {}
impl fmt::Debug for EditPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.path, f)
}
}

View file

@ -1,18 +0,0 @@
use super::{Dir, DirEntry, VPath};
#[derive(Debug)]
pub struct EmptyEntry;
impl Dir for EmptyEntry {
fn dir(&self, _path: &VPath) -> Vec<DirEntry> {
vec![]
}
fn content_version(&self, _path: &VPath) -> Option<String> {
None
}
fn content(&self, _path: &VPath) -> Option<Vec<u8>> {
None
}
}

View file

@ -1,38 +0,0 @@
use std::fmt;
use super::{DirEntry, Dir, VPath};
pub struct BufferedFile {
pub content: Vec<u8>,
}
impl BufferedFile {
pub fn new(content: Vec<u8>) -> Self {
Self { content }
}
}
impl Dir for BufferedFile {
fn dir(&self, _path: &VPath) -> Vec<DirEntry> {
vec![]
}
fn content_version(&self, _path: &VPath) -> Option<String> {
// TODO: StaticFile should _probably_ calculate a content_version.
None
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
if path == VPath::ROOT {
Some(self.content.clone())
} else {
None
}
}
}
impl fmt::Debug for BufferedFile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "BufferedFile")
}
}

View file

@ -1,89 +0,0 @@
use std::{fmt, io::Cursor};
use anyhow::Context;
use dashmap::DashMap;
use log::{debug, warn};
use crate::config;
use super::{Dir, DirEntry, EditPath, ImageSize, VPath, VPathBuf};
pub struct ImageSizeCache<T> {
inner: T,
cache: DashMap<VPathBuf, Option<ImageSize>>,
}
impl<T> ImageSizeCache<T> {
pub fn new(inner: T) -> Self {
Self {
inner,
cache: DashMap::new(),
}
}
}
impl<T> ImageSizeCache<T>
where
T: Dir,
{
fn compute_image_size(&self, path: &VPath) -> anyhow::Result<Option<ImageSize>> {
if path.extension().is_some_and(config::is_image_file) {
if let Some(content) = self.content(path) {
let reader = image::ImageReader::new(Cursor::new(content))
.with_guessed_format()
.context("cannot guess image format")?;
let (width, height) = reader.into_dimensions()?;
debug!("image_size({path}) = ({width}, {height})");
return Ok(Some(ImageSize { width, height }));
}
}
Ok(None)
}
}
impl<T> Dir for ImageSizeCache<T>
where
T: Dir,
{
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
self.inner.dir(path)
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
self.inner.content(path)
}
fn content_version(&self, path: &VPath) -> Option<String> {
self.inner.content_version(path)
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
self.cache
.entry(path.to_owned())
.or_insert_with(|| {
self.compute_image_size(path)
.inspect_err(|err| warn!("compute_image_size({path}) failed: {err:?}"))
.ok()
.flatten()
})
.clone()
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
self.inner.anchor(path)
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
self.inner.edit_path(path)
}
}
impl<T> fmt::Debug for ImageSizeCache<T>
where
T: fmt::Debug,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ImageSizeCache({:?})", self.inner)
}
}

View file

@ -1,151 +0,0 @@
use std::{collections::HashMap, fmt};
use super::{Dir, DirEntry, DynDir, EditPath, ImageSize, VPath, VPathBuf};
pub struct MemDir {
mount_points: HashMap<String, DynDir>,
}
enum Resolved<'fs, 'path> {
Root,
MountPoint {
fs: &'fs dyn Dir,
fs_path: &'path VPath,
subpath: &'path VPath,
},
None,
}
impl MemDir {
pub fn new() -> Self {
Self {
mount_points: HashMap::new(),
}
}
pub fn add(&mut self, path: &VPath, dir: DynDir) {
assert_eq!(
path.depth(), 0,
"path must be situated at root. MountPoints does not support nested paths, but you can nest MountPoints within other MountPoints"
);
assert!(
self.mount_points
.insert(path.as_str().to_owned(), dir)
.is_none(),
"duplicate mount point at {path:?}"
);
}
fn resolve<'fs, 'path>(&'fs self, path: &'path VPath) -> Resolved<'fs, 'path> {
if path == VPath::ROOT {
return Resolved::Root;
} else {
let mount_point_name = path.as_str().split(VPath::SEPARATOR).next().unwrap();
if let Some(mount_point) = self.mount_points.get(mount_point_name) {
return Resolved::MountPoint {
fs: &**mount_point,
fs_path: VPath::new(mount_point_name),
subpath: path
.strip_prefix(VPath::new(mount_point_name))
.expect("path should have `mount_point_name` as its prefix"),
};
}
}
Resolved::None
}
}
impl Default for MemDir {
fn default() -> Self {
Self::new()
}
}
impl Dir for MemDir {
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
match self.resolve(path) {
Resolved::Root => self
.mount_points
.keys()
.map(|name| DirEntry {
path: VPathBuf::new(name),
})
.collect(),
Resolved::MountPoint {
fs,
fs_path,
subpath,
} => fs
.dir(subpath)
.into_iter()
.map(|entry| DirEntry {
path: fs_path.join(&entry.path),
})
.collect(),
Resolved::None => vec![],
}
}
fn content_version(&self, path: &VPath) -> Option<String> {
match self.resolve(path) {
Resolved::MountPoint {
fs,
fs_path: _,
subpath,
} => fs.content_version(subpath),
Resolved::Root | Resolved::None => None,
}
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
match self.resolve(path) {
Resolved::MountPoint {
fs,
fs_path: _,
subpath,
} => fs.content(subpath),
Resolved::Root | Resolved::None => None,
}
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
match self.resolve(path) {
Resolved::MountPoint {
fs,
fs_path: _,
subpath,
} => fs.image_size(subpath),
Resolved::Root | Resolved::None => None,
}
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
match self.resolve(path) {
Resolved::MountPoint {
fs,
fs_path: _,
subpath,
} => fs.anchor(subpath),
Resolved::Root | Resolved::None => None,
}
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
match self.resolve(path) {
Resolved::MountPoint {
fs,
fs_path: _,
subpath,
} => fs.edit_path(subpath),
Resolved::Root | Resolved::None => None,
}
}
}
impl fmt::Debug for MemDir {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("MountPoints")
}
}

View file

@ -1,58 +0,0 @@
use std::fmt;
use super::{Dir, DirEntry, DynDir, EditPath, ImageSize, VPath, VPathBuf};
pub struct Overlay {
base: DynDir,
overlay: DynDir,
}
impl Overlay {
pub fn new(base: DynDir, overlay: DynDir) -> Self {
Self { base, overlay }
}
}
impl Dir for Overlay {
fn dir(&self, path: &VPath) -> Vec<DirEntry> {
let mut dir = self.base.dir(path);
dir.append(&mut self.overlay.dir(path));
dir.sort();
dir.dedup();
dir
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
self.overlay
.content(path)
.or_else(|| self.base.content(path))
}
fn content_version(&self, path: &VPath) -> Option<String> {
self.overlay
.content_version(path)
.or_else(|| self.base.content_version(path))
}
fn image_size(&self, path: &VPath) -> Option<ImageSize> {
self.overlay
.image_size(path)
.or_else(|| self.base.image_size(path))
}
fn anchor(&self, path: &VPath) -> Option<VPathBuf> {
self.overlay.anchor(path).or_else(|| self.base.anchor(path))
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
self.overlay
.edit_path(path)
.or_else(|| self.base.edit_path(path))
}
}
impl fmt::Debug for Overlay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Overlay({:?}, {:?})", self.base, self.overlay)
}
}

View file

@ -1,316 +0,0 @@
use std::{borrow::Borrow, error::Error, fmt, ops::Deref, str::FromStr};
use serde::{Deserialize, Serialize};
#[derive(PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct VPath {
path: str,
}
impl VPath {
pub const SEPARATOR_BYTE: u8 = b'/';
pub const SEPARATOR: char = Self::SEPARATOR_BYTE as char;
pub const ROOT: &Self = unsafe { Self::new_unchecked("") };
pub const fn try_new(s: &str) -> Result<&Self, InvalidPathError> {
if s.is_empty() {
return Ok(Self::ROOT);
}
let b = s.as_bytes();
if b[b.len() - 1] == Self::SEPARATOR_BYTE {
return Err(InvalidPathError::TrailingSlash);
}
if b[0] == Self::SEPARATOR_BYTE {
return Err(InvalidPathError::LeadingSlash);
}
Ok(unsafe { Self::new_unchecked(s) })
}
pub fn new(s: &str) -> &Self {
Self::try_new(s).expect("invalid path")
}
/// `const` version of [`new`][Self::new]. This has worse error messages, so prefer `new` whenever possible.
pub const fn new_const(s: &str) -> &Self {
match Self::try_new(s) {
Ok(p) => p,
Err(_) => panic!("invalid path"),
}
}
const unsafe fn new_unchecked(s: &str) -> &Self {
std::mem::transmute::<_, &Self>(s)
}
pub fn is_empty(&self) -> bool {
self.path.is_empty()
}
pub fn is_root(&self) -> bool {
self.is_empty()
}
pub fn join(&self, sub: &VPath) -> VPathBuf {
let mut buf = self.to_owned();
buf.push(sub);
buf
}
pub fn parent(&self) -> Option<&VPath> {
if self.is_root() {
None
} else if self.depth() == 0 {
Some(VPath::ROOT)
} else {
let (left, _right) = self
.path
.split_once(Self::SEPARATOR)
.expect("path with depth > 0 must have separators");
// SAFETY: We're splitting on a `/`, so there cannot be a trailing `/` in `left`.
Some(unsafe { VPath::new_unchecked(left) })
}
}
pub fn strip_prefix(&self, prefix: &VPath) -> Option<&Self> {
if self == prefix {
Some(VPath::ROOT)
} else {
self.path
.strip_prefix(&prefix.path)
.and_then(|p| p.strip_prefix(Self::SEPARATOR))
// SAFETY: If `self` starts with `prefix`, `p` will end up not being prefixed by `self`
// nor a leading slash.
.map(|p| unsafe { VPath::new_unchecked(p) })
}
}
pub fn depth(&self) -> usize {
self.path.chars().filter(|&c| c == Self::SEPARATOR).count()
}
pub fn segments(&self) -> impl Iterator<Item = &Self> {
if self.is_root() {
None.into_iter().flatten()
} else {
Some(self.as_str().split(Self::SEPARATOR).map(|s| unsafe {
// SAFETY: Since we're splitting on the separator, the path cannot start or end with it.
Self::new_unchecked(s)
}))
.into_iter()
.flatten()
}
}
pub fn rsegments(&self) -> impl Iterator<Item = &Self> {
self.as_str().rsplit(Self::SEPARATOR).map(|s| unsafe {
// SAFETY: Since we're splitting on the separator, the path cannot start or end with it.
Self::new_unchecked(s)
})
}
pub fn file_name(&self) -> Option<&str> {
self.rsegments().next().map(Self::as_str)
}
pub fn extension(&self) -> Option<&str> {
let file_name = self.file_name()?;
let (left, right) = file_name.rsplit_once('.')?;
if left.is_empty() {
None
} else {
Some(right)
}
}
pub fn with_extension(&self, extension: &str) -> VPathBuf {
let mut buf = self.to_owned();
buf.set_extension(extension);
buf
}
pub fn file_stem(&self) -> Option<&str> {
let file_name = self.file_name()?;
if let Some(extension) = self.extension() {
Some(&file_name[..file_name.len() - extension.len() - 1])
} else {
Some(file_name)
}
}
pub fn as_str(&self) -> &str {
&self.path
}
}
impl ToOwned for VPath {
type Owned = VPathBuf;
fn to_owned(&self) -> Self::Owned {
VPathBuf::from(self)
}
}
impl fmt::Debug for VPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.path)
}
}
impl fmt::Display for VPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.path)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvalidPathError {
TrailingSlash,
LeadingSlash,
}
impl fmt::Display for InvalidPathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InvalidPathError::TrailingSlash => {
f.write_str("paths must not end with a trailing `/`")
}
InvalidPathError::LeadingSlash => {
f.write_str("paths are always absolute and must not start with `/`")
}
}
}
}
impl Error for InvalidPathError {}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct VPathBuf {
path: String,
}
impl VPathBuf {
pub fn new(path: impl Into<String>) -> Self {
Self::try_new(path).expect("invalid path")
}
pub fn try_new(path: impl Into<String>) -> Result<Self, InvalidPathError> {
let path = path.into();
match VPath::try_new(&path) {
Ok(_) => Ok(Self { path }),
Err(e) => Err(e),
}
}
unsafe fn new_unchecked(path: String) -> Self {
Self { path }
}
pub fn push(&mut self, sub: &VPath) {
if !sub.is_empty() {
if !self.is_empty() {
self.path.push('/');
}
self.path.push_str(&sub.path);
}
}
pub fn set_extension(&mut self, new_extension: &str) {
if let Some(existing) = self.extension() {
let mut chop_len = existing.len();
if new_extension.is_empty() {
chop_len += 1; // also chop off the `.`
}
let range = self.path.len() - chop_len..;
self.path.replace_range(range, new_extension);
} else {
self.path.push('.');
self.path.push_str(new_extension);
}
}
}
impl Default for VPathBuf {
fn default() -> Self {
VPath::ROOT.to_owned()
}
}
impl Deref for VPathBuf {
type Target = VPath;
fn deref(&self) -> &Self::Target {
unsafe { VPath::new_unchecked(&self.path) }
}
}
impl fmt::Debug for VPathBuf {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.path)
}
}
impl fmt::Display for VPathBuf {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.path)
}
}
impl From<&VPath> for VPathBuf {
fn from(value: &VPath) -> Self {
unsafe { Self::new_unchecked(value.path.to_owned()) }
}
}
impl Borrow<VPath> for VPathBuf {
fn borrow(&self) -> &VPath {
self
}
}
impl<'de> Deserialize<'de> for VPathBuf {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct Visitor;
impl de::Visitor<'_> for Visitor {
type Value = VPathBuf;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("virtual path")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
VPathBuf::try_new(v).map_err(de::Error::custom)
}
}
deserializer.deserialize_str(Visitor)
}
}
impl Serialize for VPathBuf {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl FromStr for VPathBuf {
type Err = InvalidPathError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_new(s)
}
}

View file

@ -1,81 +0,0 @@
use std::path::{Path, PathBuf};
use log::error;
use super::{Dir, DirEntry, EditPath, VPath, VPathBuf};
#[derive(Debug, Clone)]
pub struct PhysicalDir {
root: PathBuf,
}
impl PhysicalDir {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
}
impl Dir for PhysicalDir {
fn dir(&self, vpath: &VPath) -> Vec<DirEntry> {
let physical = self.root.join(physical_path(vpath));
if !physical.is_dir() {
return vec![];
}
match std::fs::read_dir(physical) {
Ok(read_dir) => read_dir
.filter_map(|entry| {
entry
.inspect_err(|err| {
error!(
"{self:?} error while reading entries: {err:?}",
)
})
.ok()
.and_then(|entry| {
let path = entry.path();
let path_str = match path.strip_prefix(&self.root).unwrap_or(&path).to_str() {
Some(p) => p,
None => {
error!("{self:?} entry {path:?} has invalid UTF-8 (while reading vpath {vpath:?})");
return None;
},
};
let vpath_buf = VPathBuf::try_new(path_str.replace('\\', "/"))
.inspect_err(|err| {
error!("{self:?} error with vpath for {path_str:?}: {err:?}");
})
.ok()?;
Some(DirEntry { path: vpath_buf })
})
})
.collect(),
Err(err) => {
error!(
"{self:?} cannot read vpath {vpath:?}: {err:?}",
);
vec![]
}
}
}
fn content_version(&self, _path: &VPath) -> Option<String> {
None
}
fn content(&self, path: &VPath) -> Option<Vec<u8>> {
std::fs::read(self.root.join(physical_path(path)))
.inspect_err(|err| error!("{self:?} cannot read file at vpath {path:?}: {err:?}",))
.ok()
}
fn edit_path(&self, path: &VPath) -> Option<EditPath> {
Some(EditPath {
path: self.root.join(physical_path(path)),
})
}
}
fn physical_path(path: &VPath) -> &Path {
Path::new(path.as_str())
}

View file

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

View file

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

View file

@ -1,83 +0,0 @@
use treehouse::vfs::{BufferedFile, Cd, Dir, DirEntry, MemDir, ToDynDir, VPath, VPathBuf};
const HEWWO: &[u8] = b"hewwo :3";
const FWOOFEE: &[u8] = b"fwoofee -w-";
const BOOP: &[u8] = b"boop >w<";
fn vfs() -> MemDir {
let file1 = BufferedFile::new(HEWWO.to_vec());
let file2 = BufferedFile::new(FWOOFEE.to_vec());
let file3 = BufferedFile::new(BOOP.to_vec());
let mut innermost = MemDir::new();
innermost.add(VPath::new("file3.txt"), file3.to_dyn());
let mut inner = MemDir::new();
inner.add(VPath::new("file1.txt"), file1.to_dyn());
inner.add(VPath::new("file2.txt"), file2.to_dyn());
inner.add(VPath::new("innermost"), innermost.to_dyn());
let mut vfs = MemDir::new();
vfs.add(VPath::new("inner"), inner.to_dyn());
vfs
}
#[test]
fn dir1() {
let outer = vfs();
let inner = Cd::new(outer, VPathBuf::new("inner"));
let mut dir = inner.dir(VPath::ROOT);
dir.sort();
assert_eq!(
dir,
vec![
DirEntry {
path: VPathBuf::new("file1.txt"),
},
DirEntry {
path: VPathBuf::new("file2.txt"),
},
DirEntry {
path: VPathBuf::new("innermost"),
}
]
);
}
#[test]
fn dir2() {
let outer = vfs();
let innermost = Cd::new(&outer, VPathBuf::new("inner/innermost"));
let mut dir = innermost.dir(VPath::ROOT);
dir.sort();
assert_eq!(
dir,
vec![DirEntry {
path: VPathBuf::new("file3.txt"),
},]
);
}
#[test]
fn content_version() {
let outer = vfs();
let inner = Cd::new(&outer, VPathBuf::new("inner"));
assert_eq!(
inner.content_version(VPath::new("test1.txt")),
outer.content_version(VPath::new("inner/test1.txt"))
);
}
#[test]
fn content() {
let outer = vfs();
let inner = Cd::new(&outer, VPathBuf::new("inner"));
assert_eq!(
inner.content(VPath::new("test1.txt")),
outer.content(VPath::new("inner/test1.txt"))
);
}

View file

@ -1,16 +0,0 @@
use treehouse::vfs::{Dir, EmptyEntry, VPath};
#[test]
fn dir() {
assert!(EmptyEntry.dir(VPath::ROOT).is_empty());
}
#[test]
fn content_version() {
assert!(EmptyEntry.content_version(VPath::ROOT).is_none());
}
#[test]
fn content() {
assert!(EmptyEntry.content(VPath::ROOT).is_none());
}

View file

@ -1,29 +0,0 @@
use treehouse::vfs::{BufferedFile, Dir, VPath};
fn vfs() -> BufferedFile {
BufferedFile::new(b"hewwo :3".to_vec())
}
#[test]
fn dir() {
let vfs = vfs();
assert!(vfs.dir(VPath::ROOT).is_empty());
}
#[test]
fn content_version() {
let vfs = vfs();
assert!(
vfs.content_version(VPath::ROOT).is_none(),
"content_version is not implemented for BufferedFile for now"
);
}
#[test]
fn content() {
let vfs = vfs();
assert_eq!(
vfs.content(VPath::ROOT).as_deref(),
Some(b"hewwo :3".as_slice()),
);
}

View file

@ -1,88 +0,0 @@
use treehouse::vfs::{BufferedFile, Dir, DirEntry, MemDir, ToDynDir, VPath, VPathBuf};
const HEWWO: &[u8] = b"hewwo :3";
const FWOOFEE: &[u8] = b"fwoofee -w-";
const BOOP: &[u8] = b"boop >w<";
fn vfs() -> MemDir {
let file1 = BufferedFile::new(HEWWO.to_vec());
let file2 = BufferedFile::new(FWOOFEE.to_vec());
let file3 = BufferedFile::new(BOOP.to_vec());
let mut inner = MemDir::new();
inner.add(VPath::new("file3.txt"), file3.to_dyn());
let mut vfs = MemDir::new();
vfs.add(VPath::new("file1.txt"), file1.to_dyn());
vfs.add(VPath::new("file2.txt"), file2.to_dyn());
vfs.add(VPath::new("inner"), inner.to_dyn());
vfs
}
#[test]
fn dir() {
let vfs = vfs();
let mut dir = vfs.dir(VPath::new(""));
dir.sort();
assert_eq!(
dir,
vec![
DirEntry {
path: VPathBuf::new("file1.txt"),
},
DirEntry {
path: VPathBuf::new("file2.txt"),
},
DirEntry {
path: VPathBuf::new("inner"),
}
]
);
assert!(vfs.dir(VPath::new("file1.txt")).is_empty());
assert!(vfs.dir(VPath::new("file2.txt")).is_empty());
assert_eq!(
vfs.dir(VPath::new("inner")),
vec![DirEntry {
path: VPathBuf::new("inner/file3.txt")
}]
);
}
#[test]
fn content_version() {
let vfs = vfs();
let file1 = BufferedFile::new(HEWWO.to_vec());
let file2 = BufferedFile::new(FWOOFEE.to_vec());
let file3 = BufferedFile::new(BOOP.to_vec());
assert_eq!(
vfs.content_version(VPath::new("file1.txt")),
file1.content_version(VPath::ROOT)
);
assert_eq!(
vfs.content_version(VPath::new("file2.txt")),
file2.content_version(VPath::ROOT)
);
assert_eq!(
vfs.content_version(VPath::new("inner/file3.txt")),
file3.content_version(VPath::ROOT)
);
}
#[test]
fn content() {
let vfs = vfs();
assert_eq!(vfs.content(VPath::new("file1.txt")).as_deref(), Some(HEWWO));
assert_eq!(
vfs.content(VPath::new("file2.txt")).as_deref(),
Some(FWOOFEE)
);
assert_eq!(
vfs.content(VPath::new("inner/file3.txt")).as_deref(),
Some(BOOP)
);
}

View file

@ -1,37 +0,0 @@
use std::path::Path;
use treehouse::vfs::{DirEntry, PhysicalDir, Dir, VPath, VPathBuf};
fn vfs() -> PhysicalDir {
let root = Path::new("tests/it/vfs_physical").to_path_buf();
PhysicalDir::new(root)
}
#[test]
fn dir() {
let vfs = vfs();
let dir = vfs.dir(VPath::ROOT);
assert_eq!(
&dir[..],
&[DirEntry {
path: VPathBuf::new("test.txt"),
}]
);
}
#[test]
fn content_version() {
let vfs = vfs();
let content_version = vfs.content_version(VPath::new("test.txt"));
assert_eq!(
content_version, None,
"content_version remains unimplemented for now"
);
}
#[test]
fn content() {
let vfs = vfs();
let content = vfs.content(VPath::new("test.txt"));
assert_eq!(content.as_deref(), Some(b"hewwo :3\n".as_slice()));
}

View file

@ -1 +0,0 @@
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?v=b3-445487d5"); src: url("../font/Recursive_VF_1.085.woff2?cache=b3-445487d5");
} }
@font-face { @font-face {
font-family: "RecVarMono"; font-family: "RecVarMono";
src: url("../font/Recursive_VF_1.085.woff2?v=b3-445487d5"); src: url("../font/Recursive_VF_1.085.woff2?cache=b3-445487d5");
font-variation-settings: "MONO" 1; font-variation-settings: "MONO" 1;
} }

View file

@ -1,16 +0,0 @@
// NOTE: The server never fulfills this request, it stalls forever.
// Once the connection is closed, we try to connect with the server until we establish a successful
// connection. Then we reload the page.
await fetch("/dev/live-reload/stall").catch(async () => {
while (true) {
try {
let response = await fetch("/dev/live-reload/back-up");
if (response.status == 200) {
window.location.reload();
break;
}
} catch (e) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
});

View file

@ -104,7 +104,9 @@ class LinkedBranch extends Branch {
async loadTreePromise(_initiator) { async loadTreePromise(_initiator) {
try { try {
let response = await fetch(`${TREEHOUSE_SITE}/${this.linkedTree}`); let response = await fetch(
`${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.`;
} }
@ -125,9 +127,7 @@ 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( let url = URL.createObjectURL(new Blob([script.textContent], { type: "text/javascript" }))
new Blob([script.textContent], { type: "text/javascript" }),
);
import(url); import(url);
} }
} catch (error) { } catch (error) {
@ -257,7 +257,10 @@ 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 (linkedBranch.children.length > 0 && linkedBranch.children[0].tagName == "DETAILS") { if (
linkedBranch.children.length > 0 &&
linkedBranch.children[0].tagName == "DETAILS"
) {
expandDetailsRecursively(linkedBranch.children[0]); expandDetailsRecursively(linkedBranch.children[0]);
} }
} }

View file

@ -11,17 +11,10 @@
<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
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. --}}
clever to do while browser vendors figure that out, we'll just have to do a cache-busting string substitution. {{!-- <script type="importmap" src="{{ asset 'generated/import-map.json' }}"></script> --}}
--}} <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,8 +3,6 @@
<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' }}">
@ -23,7 +21,7 @@
} }
</style> </style>
<script type="importmap">{{{ import_map }}}</script> <script type="importmap">{{{ include_static 'generated/import-map.json' }}}</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,6 +5,9 @@
# 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"
@ -57,7 +60,7 @@ description = "a place on the Internet I like to call home"
[build.javascript] [build.javascript]
import_roots = [ import_roots = [
{ name = "treehouse", path = "" }, { name = "treehouse", path = "static/js" },
{ name = "tairu", path = "components/tairu" }, { name = "tairu", path = "static/js/components/tairu" },
{ name = "haku", path = "components/haku" }, { name = "haku", path = "static/js/components/haku" },
] ]