Commit
Message
Changed Files (16)
-
modified Cargo.lock
diff --git a/Cargo.lock b/Cargo.lock index 5cfd8b5..95158ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "clap_usage", "figment", "flate2", + "gix", "globset", "human-panic", "ignore", @@ -23,6 +24,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "syntect", "tar", "tempfile", "tera", @@ -57,6 +59,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -146,6 +154,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "atomic" version = "0.6.1" @@ -197,6 +214,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -219,6 +245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", + "regex-automata", "serde", ] @@ -234,6 +261,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -350,6 +383,15 @@ dependencies = [ "usage-lib", ] +[[package]] +name = "clru" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -428,6 +470,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -467,6 +518,12 @@ dependencies = [ "shared_thread", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -485,6 +542,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -501,6 +567,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -547,12 +623,24 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -584,81 +672,713 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "futures-util" -version = "0.3.32" +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gix" +version = "0.84.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce" +dependencies = [ + "gix-actor", + "gix-commitgraph", + "gix-config", + "gix-date", + "gix-diff", + "gix-discover", + "gix-error", + "gix-features", + "gix-fs", + "gix-glob", + "gix-hash", + "gix-hashtable", + "gix-index", + "gix-lock", + "gix-object", + "gix-odb", + "gix-pack", + "gix-path", + "gix-protocol", + "gix-ref", + "gix-refspec", + "gix-revision", + "gix-revwalk", + "gix-sec", + "gix-shallow", + "gix-tempfile", + "gix-trace", + "gix-traverse", + "gix-url", + "gix-utils", + "gix-validate", + "gix-worktree-stream", + "nonempty", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-actor" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc998b8f746dda8565450d08a63b792ced9165d8c27a1ed3f02799ec6a7820f" +dependencies = [ + "bstr", + "gix-date", + "gix-error", +] + +[[package]] +name = "gix-attributes" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d43f12e246d3bf7ec624c8fc15ac4a4b62b7c4c6f586cb82be6c90bf84c9d02" +dependencies = [ + "bstr", + "gix-glob", + "gix-path", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-bitmap" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ebef0c26ad305747649e727bbcd56a7b7910754eb7cea88f6dff6f93c51283" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-chunk" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9faee47943b638e58ddd5e275a4906ad3e4b6c8584f1d41bd18ab9032ec52afb" +dependencies = [ + "gix-error", +] + +[[package]] +name = "gix-command" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" +dependencies = [ + "bstr", + "gix-path", + "gix-quote", + "gix-trace", + "shell-words", +] + +[[package]] +name = "gix-commitgraph" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f675d0df484a7f6a47e64bd6f311af489d947c0323b0564f36d14f3d7762abb" +dependencies = [ + "bstr", + "gix-chunk", + "gix-error", + "gix-hash", + "memmap2", + "nonempty", +] + +[[package]] +name = "gix-config" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2372d4b49ca28431e7d150cab9d25edc1890f0184bd57eb0e917c7799e63de" +dependencies = [ + "bstr", + "gix-config-value", + "gix-features", + "gix-glob", + "gix-path", + "gix-ref", + "gix-sec", + "smallvec", + "thiserror", + "unicode-bom", +] + +[[package]] +name = "gix-config-value" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" +dependencies = [ + "bitflags", + "bstr", + "gix-path", + "libc", + "thiserror", +] + +[[package]] +name = "gix-date" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ecab64a98bbac9f8e02990a9ea5e3c974a7d49b95f2bd70ad94ad22fa6b48c" +dependencies = [ + "bstr", + "gix-error", + "itoa", + "jiff", +] + +[[package]] +name = "gix-diff" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b" +dependencies = [ + "bstr", + "gix-hash", + "gix-object", + "thiserror", +] + +[[package]] +name = "gix-discover" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77bacdd12b7879d2178a80c58c2f319995e4654e1a7a23e3181e5c8a12b824f7" +dependencies = [ + "bstr", + "dunce", + "gix-fs", + "gix-path", + "gix-ref", + "gix-sec", + "thiserror", +] + +[[package]] +name = "gix-error" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57831e199be480af90dcd7e459abed8a174c09ec9a6e2cc8f7ca6c54598b06b" +dependencies = [ + "bstr", +] + +[[package]] +name = "gix-features" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1849ae154d38bc403185be14fa871e38e3c93ee606875d94e207fdb9fba52dbc" +dependencies = [ + "bytes", + "crc32fast", + "gix-path", + "gix-trace", + "gix-utils", + "libc", + "once_cell", + "prodash", + "thiserror", + "walkdir", + "zlib-rs", +] + +[[package]] +name = "gix-filter" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa" +dependencies = [ + "bstr", + "encoding_rs", + "gix-attributes", + "gix-command", + "gix-hash", + "gix-object", + "gix-packetline", + "gix-path", + "gix-quote", + "gix-trace", + "gix-utils", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-fs" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" +dependencies = [ + "bstr", + "fastrand", + "gix-features", + "gix-path", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-glob" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" +dependencies = [ + "bitflags", + "bstr", + "gix-features", + "gix-path", +] + +[[package]] +name = "gix-hash" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" +dependencies = [ + "faster-hex", + "gix-features", + "sha1-checked", + "thiserror", +] + +[[package]] +name = "gix-hashtable" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e30b93eea8718baf7d8153fcb938e2926175bbf18097c09f1c01b6f0be0563" +dependencies = [ + "gix-hash", + "hashbrown 0.17.1", + "parking_lot", +] + +[[package]] +name = "gix-index" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6b28cc592dc753adb58302bb14a64e412ee591a3bec77aa4df87bff74fa80d" +dependencies = [ + "bitflags", + "bstr", + "filetime", + "fnv", + "gix-bitmap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-traverse", + "gix-utils", + "gix-validate", + "hashbrown 0.17.1", + "itoa", + "libc", + "memmap2", + "rustix", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-lock" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9dedd9e90b0d47624d2ed241d394e09294118364e87b9b7e5f1fe755f3c2c" +dependencies = [ + "gix-tempfile", + "gix-utils", + "thiserror", +] + +[[package]] +name = "gix-object" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5cd857e29429c7213bdef3f5aef83f8cc124774fe8ae0d27b1607d218d6d525" +dependencies = [ + "bstr", + "gix-actor", + "gix-date", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-utils", + "gix-validate", + "itoa", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-odb" +version = "0.81.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d" +dependencies = [ + "arc-swap", + "gix-features", + "gix-fs", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-pack", + "gix-path", + "gix-quote", + "memmap2", + "parking_lot", + "tempfile", + "thiserror", +] + +[[package]] +name = "gix-pack" +version = "0.71.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e43626f2a27d1033674ec1a196b845614231e6bbd949d5e21c133045ff56b174" +dependencies = [ + "clru", + "gix-chunk", + "gix-error", + "gix-features", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-path", + "memmap2", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-packetline" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb18337ba2830bb43367d1af43819c8c78f31337f079fc76d0f1f1750a173126" +dependencies = [ + "bstr", + "faster-hex", + "gix-trace", + "thiserror", +] + +[[package]] +name = "gix-path" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa6ac14cd14939ea94a496ce7460daa6511c09f5b84757e9cfc6f9c8d0f93a6" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror", +] + +[[package]] +name = "gix-protocol" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51dea3acb390707ab868f1f9584f18449eb95d869deffae96768e47d303595ee" +dependencies = [ + "bstr", + "gix-date", + "gix-features", + "gix-hash", + "gix-ref", + "gix-shallow", + "gix-transport", + "gix-utils", + "maybe-async", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-quote" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e541fc33cc2b783b7979040d445a0c86a2eca747c8faea4ca84230d06ae6ef" +dependencies = [ + "bstr", + "gix-error", + "gix-utils", +] + +[[package]] +name = "gix-ref" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c04f64c37eb7e6feb73c7060f8dc6f381cc5de5d53249bfd450bc48a86b2e8b" +dependencies = [ + "gix-actor", + "gix-features", + "gix-fs", + "gix-hash", + "gix-lock", + "gix-object", + "gix-path", + "gix-tempfile", + "gix-utils", + "gix-validate", + "memmap2", + "thiserror", +] + +[[package]] +name = "gix-refspec" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b216ae06ec74b5f24ad0142026a997fb0a935b7410eaf9c1616fc3f0e6c5a6d3" +dependencies = [ + "bstr", + "gix-error", + "gix-glob", + "gix-hash", + "gix-revision", + "gix-validate", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-revision" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b47c88884dd3c1a19a39da19d10211fcdea2809aadc86869b6e824a1774340f" +dependencies = [ + "bitflags", + "bstr", + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "gix-trace", + "nonempty", +] + +[[package]] +name = "gix-revwalk" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85f5756abffe0917827aac683b13684ed99875bc398fa1f9b8f479b0681ef9e6" +dependencies = [ + "gix-commitgraph", + "gix-date", + "gix-error", + "gix-hash", + "gix-hashtable", + "gix-object", + "smallvec", + "thiserror", +] + +[[package]] +name = "gix-sec" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" +dependencies = [ + "bitflags", + "gix-path", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "gix-shallow" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a292fc2fe548c5dfa575479d16b445b0ddf1dd2f56f1fec6aed386f82553cd97" +dependencies = [ + "bstr", + "gix-hash", + "gix-lock", + "nonempty", + "thiserror", +] + +[[package]] +name = "gix-tempfile" +version = "23.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27850097e1ff9515f46a0dad0f5f9c9d020e972727772dabab9450690c4adb22" +dependencies = [ + "gix-fs", + "libc", + "parking_lot", + "tempfile", +] + +[[package]] +name = "gix-trace" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] +checksum = "44dc45eae785c0eb14173e0f152e6e224dcf4d45b6a6999a3aed22af541ad678" [[package]] -name = "generic-array" -version = "0.14.7" +name = "gix-transport" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "7cd0e34995b1aab0fa8dff2af8db726a0bfad3e119c89302604463264046e7ff" dependencies = [ - "typenum", - "version_check", + "bstr", + "gix-command", + "gix-features", + "gix-packetline", + "gix-quote", + "gix-sec", + "gix-url", + "thiserror", ] [[package]] -name = "getopts" -version = "0.2.24" +name = "gix-traverse" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +checksum = "e8de590ecc86a3b2870665f2288324fa9f7f8672c7fc2d4e020fdd81cd1f7aed" dependencies = [ - "unicode-width 0.2.2", + "bitflags", + "gix-commitgraph", + "gix-date", + "gix-hash", + "gix-hashtable", + "gix-object", + "gix-revwalk", + "smallvec", + "thiserror", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "gix-url" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "65bb01ec69d55e82ccb7a19e264501ead4e6aac38463a8cebfdd81e22bb67ab2" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", + "bstr", + "gix-path", + "percent-encoding", + "thiserror", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "gix-utils" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "66c50966184123caf580ffa64e28031a878597f1c7fceb8fe19566c38eb1b771" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", + "fastrand", + "unicode-normalization", ] [[package]] -name = "getrandom" -version = "0.4.2" +name = "gix-validate" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "7bc6fc771c4063ba7cd2f47b91fb6076251c6a823b64b7fe7b8874b0fe4afae3" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", + "bstr", ] [[package]] -name = "gimli" -version = "0.32.3" +name = "gix-worktree-stream" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +checksum = "d25e9ed30100c63f7590bc581c225e53f731a53e06aa79a245739c07f7dcc557" +dependencies = [ + "gix-attributes", + "gix-error", + "gix-features", + "gix-filter", + "gix-fs", + "gix-hash", + "gix-object", + "gix-path", + "gix-traverse", + "parking_lot", +] [[package]] name = "globset" @@ -684,13 +1404,33 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -698,6 +1438,21 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heck" @@ -1053,6 +1808,47 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +dependencies = [ + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-static" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1076,6 +1872,15 @@ dependencies = [ "winnow 0.6.24", ] +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "static_assertions", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1100,6 +1905,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1142,12 +1953,32 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "miette" version = "7.6.0" @@ -1221,6 +2052,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nonempty" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" + [[package]] name = "ntapi" version = "0.4.3" @@ -1272,6 +2109,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-integer" version = "0.1.46" @@ -1348,6 +2191,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -1512,12 +2377,40 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1527,6 +2420,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1568,6 +2467,15 @@ dependencies = [ "yansi", ] +[[package]] +name = "prodash" +version = "31.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" +dependencies = [ + "parking_lot", +] + [[package]] name = "pulldown-cmark" version = "0.12.2" @@ -1587,6 +2495,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2040,6 +2957,27 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest", + "sha1", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2170,6 +3108,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -2255,6 +3199,27 @@ dependencies = [ "syn", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + [[package]] name = "sysinfo" version = "0.34.2" @@ -2363,6 +3328,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2667,6 +3663,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bom" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eec5d1121208364f6793f7d2e222bf75a915c19557537745b195b253dd64217" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2679,6 +3681,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -3501,6 +4512,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "yansi" version = "1.0.1" @@ -3610,6 +4630,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" -
modified Cargo.toml
diff --git a/Cargo.toml b/Cargo.toml index bcc9c24..e01c89c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ semver = "1" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1" sha2 = "0.10" +syntect = { version = "5", features = ["html"] } tar = "0.4" tempfile = "3.27.0" tera = "1" @@ -36,6 +37,11 @@ toml = { version = "1.1.2", features = ["serde"] } tracing = { version = "0.1.44", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +[dependencies.gix] +version = "0.84" +default-features = false +features = ["revision", "sha1"] + [dependencies.reqwest] version = "0.12" default-features = false -
modified abbaye.toml
diff --git a/abbaye.toml b/abbaye.toml index bc504bf..57be956 100644 --- a/abbaye.toml +++ b/abbaye.toml @@ -10,17 +10,20 @@ fediverse_creator = "@ololduck@vit.am" image = "latest/logo-wordmark.svg" image_alt = "Abbaye logo" +[git_ui] +default_branch = "main" + [version_extractor] type = "git" tag_prefix = "v" [changelog] -[[builders]] # builds the project using cargo build --release +[[builders]] # builds the project using cargo build --release type = "cargo" targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] -[[builders]] # generates documentation using cargo doc +[[builders]] # generates documentation using cargo doc type = "cargo_doc" no_deps = true @@ -35,5 +38,5 @@ outputs = ["target/abbaye.schema.json"] [[builders]] id = "archive source" -type = "archive" # creates a compressed tarball of the source code (can be of anything, really) +type = "archive" # creates a compressed tarball of the source code (can be of anything, really) output = "target/abbaye-source.tar.gz" -
modified src/cli.rs
diff --git a/src/cli.rs b/src/cli.rs index 50aef4d..dcaf3bb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -21,7 +21,12 @@ pub enum CliCommand { path: Option<PathBuf>, }, /// Build the site - Build, + Build { + /// Only regenerate the git repository web UI; skip the version page builds. + /// Requires `[git_ui]` to be configured in `abbaye.toml`. + #[arg(long)] + repository_only: bool, + }, /// Build the site for every git tag, starting from the lowest semver version. /// Checks out each tag in order, builds, then restores the original HEAD. BuildAll, -
modified src/config.rs
diff --git a/src/config.rs b/src/config.rs index 96688ea..1721b72 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,6 +56,29 @@ pub struct OpenGraphConfig { pub author: Option<String>, } +/// Configuration for the git repository web UI. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct GitUiConfig { + #[serde(default = "default_branch")] + pub default_branch: String, + /// Maximum number of commits to show in the log page. Defaults to 200. + #[serde(default = "default_max_commits")] + pub max_commits: usize, + /// Path to the git repository to read. Defaults to `.` (the current directory). + pub repo_path: Option<PathBuf>, + /// Explicit clone URL displayed in the UI (e.g. `"https://example.com/repository.git"`). + /// When absent, derived from `site.base_url` by appending `/repository.git`. + pub clone_url: Option<String>, +} + +fn default_branch() -> String { + "main".to_string() +} + +fn default_max_commits() -> usize { + 200 +} + /// A full configuration for the Abbaye site generator. /// /// Here's a sample configuration that works well as a starting point for rust projects: @@ -93,6 +116,11 @@ pub struct AbbayeConfig { pub changelog: ChangelogConfig, /// Builders to run during the build process. pub builders: Vec<BuilderEntry>, + /// Optional git repository web UI. When present, abbaye generates browsable HTML + /// pages at `<output>/repository/` and a clonable bare repository at + /// `<output>/repository.git/`. + #[serde(default)] + pub git_ui: Option<GitUiConfig>, } fn abbaye_output_dir() -> PathBuf { -
added src/git_ui.rs
diff --git a/src/git_ui.rs b/src/git_ui.rs new file mode 100644 index 0000000..5afe8e6 --- /dev/null +++ b/src/git_ui.rs @@ -0,0 +1,1134 @@ +//! Generates a static git repository web UI and a clonable bare clone. +//! +//! Produces: +//! - `<output>/repository/` — one HTML log page per branch, refs page, +//! per-commit detail pages +//! - `<output>/repository.git/` — bare clone suitable for dumb HTTP serving +//! +//! The branch named by `git_ui.default_branch` is rendered to `index.html`; +//! every other branch gets `<sanitized-name>.html`. +//! +//! It also generates `<output>/repository/browse/<hash>/` — a full recursive +//! static file tree browser with server-side syntax highlighting (via syntect), +//! generated for every branch tip and every tagged commit. +//! +//! This is a site-level step called once from `main.rs`, not a per-version Builder. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use gix::bstr::ByteSlice; +use miette::{IntoDiagnostic, Result}; +use serde::Serialize; +use tera::{Context, Tera}; +use tracing::warn; + +use crate::config::{AbbayeConfig, GitUiConfig}; + +// ── Template sources ────────────────────────────────────────────────────────── + +pub const TEMPLATE_GIT_LOG: &str = include_str!("templates/git_log.html.j2"); +pub const TEMPLATE_GIT_COMMIT: &str = include_str!("templates/git_commit.html.j2"); +pub const TEMPLATE_GIT_REFS: &str = include_str!("templates/git_refs.html.j2"); +pub const TEMPLATE_GIT_TREE: &str = include_str!("templates/git_tree.html.j2"); +pub const TEMPLATE_GIT_BLOB: &str = include_str!("templates/git_blob.html.j2"); + +// ── Template-facing data structures ────────────────────────────────────────── + +#[derive(Clone, Serialize)] +struct CommitParent { + hash: String, + hash_short: String, +} + +/// A single commit's metadata, passed to Tera templates. +/// `Clone` is required so commits can be deduplicated across branches. +#[derive(Clone, Serialize)] +struct CommitInfo { + hash: String, + hash_short: String, + author_name: String, + author_email: String, + /// ISO-8601 timestamp for `<time datetime="">`. + date_iso: String, + /// Short date for the log table (YYYY-MM-DD). + date: String, + /// Date + time for the commit detail page. + datetime_display: String, + /// First line of the commit message. + subject: String, + /// Everything after the blank line separator, if present. + body: Option<String>, + parents: Vec<CommitParent>, + /// Tags and branches whose tip is exactly this commit. + ref_badges: Vec<RefBadge>, +} + +#[derive(Serialize)] +struct RefInfo { + name: String, + short_name: String, + hash: String, + hash_short: String, +} + +/// The visual kind of a single unified-diff line. +/// Serialises as lowercase for use as a CSS modifier class. +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +enum DiffLineKind { + Header, // diff --git, index, ---, +++, mode lines + Hunk, // @@ -a,b +c,d @@ + Added, // lines beginning with + + Removed, // lines beginning with - + Context, // unchanged surrounding lines +} + +#[derive(Serialize)] +struct DiffLine { + kind: DiffLineKind, + content: String, +} + +#[derive(Serialize)] +struct ChangedFile { + path: String, + /// One of: added, deleted, modified, renamed, copied, changed. + status: String, + diff_lines: Vec<DiffLine>, +} + +/// Discriminates the two kinds of ref badge shown on log pages. +/// Serialises as lowercase (`"tag"` / `"branch"`) for use as a CSS modifier class. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "lowercase")] +enum RefBadgeKind { + /// Derived `Ord`: `Tag < Branch`, so tags sort before branches. + Tag, + Branch, +} + +/// A ref badge shown next to a commit hash on log pages. +#[derive(Clone, Serialize)] +struct RefBadge { + label: String, + kind: RefBadgeKind, +} + +/// One level in the breadcrumb navigation on tree and blob pages. +#[derive(Serialize)] +struct Crumb { + name: String, + /// Relative link to this directory's `index.html`. `None` for the last + /// (current) segment — rendered as plain text, not a link. + url: Option<String>, +} + +/// Kind of an entry in a directory listing. Serialises as lowercase. +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +enum TreeEntryKind { + Tree, + Blob, +} + +/// One row in a directory listing. +#[derive(Serialize)] +struct TreeEntry { + name: String, + kind: TreeEntryKind, + /// Relative URL from the current tree page to this entry's page. + url: String, +} + +/// One entry in the branch-switcher nav rendered on every log page. +#[derive(Serialize)] +struct BranchNav { + short_name: String, + /// HTML filename for this branch ("index.html" or "<name>.html"). + filename: String, + is_current: bool, +} + +// ── Internal branch descriptor (never serialized) ──────────────────────────── + +#[derive(Clone)] +struct BranchEntry { + short_name: String, + /// Output filename: "index.html" for the default branch, else a sanitized name. + filename: String, + tip: gix::ObjectId, +} + +// ── Public entry point ──────────────────────────────────────────────────────── + +/// Generate the repository web UI and bare clone into `config.site.output_dir`. +pub async fn build_git_repository_ui(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Result<()> { + let output_dir = &config.site.output_dir; + let ui_dir = output_dir.join("repository"); + let bare_dir = output_dir.join("repository.git"); + + tokio::fs::create_dir_all(&ui_dir).await.into_diagnostic()?; + tokio::fs::create_dir_all(ui_dir.join("commit")) + .await + .into_diagnostic()?; + + let repo_path: PathBuf = git_cfg + .repo_path + .clone() + .unwrap_or_else(|| PathBuf::from(".")); + + let max_commits = git_cfg.max_commits; + let default_branch = git_cfg.default_branch.clone(); + let repo_path_clone = repo_path.clone(); + + // Compute clone URL before the blocking task so we can pass it into the + // browse page generator without re-deriving it. + let clone_url = generate_clone_command(config, git_cfg); + + // ── All gix work happens inside one blocking task (Repository is !Send) ─── + // + // Returns: + // branch_pages — (short_name, filename, commits) per branch + // unique_commits — all commits across all branches, deduplicated + // tags / ref_branches — for refs.html + // browse_revisions — (hex_hash, ObjectId) for every branch tip + tag tip + let (branch_pages, unique_commits, tags, ref_branches, browse_revisions) = + tokio::task::spawn_blocking(move || -> Result<_> { + let repo = match gix::discover(&repo_path_clone) { + Ok(r) => r, + Err(e) => { + warn!( + "git_ui: could not open repository at {}: {e}", + repo_path_clone.display() + ); + return Ok((vec![], vec![], vec![], vec![], vec![])); + } + }; + + let branches = collect_branch_entries(&repo, &default_branch)?; + let (tags, ref_branches) = collect_refs(&repo)?; + let ref_labels = build_ref_labels(&repo)?; + + // Walk commits per branch; collect unique commits for detail pages. + let mut unique_map: HashMap<String, CommitInfo> = HashMap::new(); + let mut branch_pages: Vec<(String, String, Vec<CommitInfo>)> = Vec::new(); + + for branch in &branches { + let commits = collect_commits(&repo, branch.tip, max_commits, &ref_labels)?; + for c in &commits { + unique_map + .entry(c.hash.clone()) + .or_insert_with(|| c.clone()); + } + branch_pages.push((branch.short_name.clone(), branch.filename.clone(), commits)); + } + + let unique_commits: Vec<CommitInfo> = unique_map.into_values().collect(); + + // Collect revisions for the tree browser: branch tips + tag tips. + let mut seen: HashMap<String, gix::ObjectId> = HashMap::new(); + for branch in &branches { + seen.insert(branch.tip.to_string(), branch.tip); + } + let refs_platform = repo.references().into_diagnostic()?; + for reference in refs_platform.all().into_diagnostic()? { + let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; + let name = reference.name().as_bstr().to_str_lossy().into_owned(); + if !name.starts_with("refs/tags/") { + continue; + } + if let Ok(id) = reference.peel_to_id() { + let hash = id.to_string(); + seen.entry(hash).or_insert_with(|| id.detach()); + } + } + let browse_revisions: Vec<(String, gix::ObjectId)> = seen.into_iter().collect(); + + Ok(( + branch_pages, + unique_commits, + tags, + ref_branches, + browse_revisions, + )) + }) + .await + .into_diagnostic()??; + + if branch_pages.is_empty() { + // Nothing to render (empty repo or no branches). + return Ok(()); + } + + // ── Bare clone + dumb HTTP setup ────────────────────────────────────────── + export_bare_clone(&repo_path, &bare_dir).await?; + + // ── Tera setup ──────────────────────────────────────────────────────────── + let mut tera = Tera::default(); + let theme_path = PathBuf::from(".abbaye").join("theme"); + for (name, builtin) in [ + ("git_log.html", TEMPLATE_GIT_LOG), + ("git_commit.html", TEMPLATE_GIT_COMMIT), + ("git_refs.html", TEMPLATE_GIT_REFS), + ] { + let override_path = theme_path.join(format!("{name}.j2")); + if override_path.is_file() { + tera.add_template_file(&override_path, Some(name)) + .into_diagnostic()?; + } else { + tera.add_raw_template(name, builtin).into_diagnostic()?; + } + } + + // ── Branch switcher nav (shared across all log pages) ───────────────────── + // + // Order: the default branch (index.html) first, then alphabetical. + let mut nav_entries: Vec<(String, String)> = branch_pages + .iter() + .map(|(name, file, _)| (name.clone(), file.clone())) + .collect(); + nav_entries.sort_by(|(na, fa), (nb, fb)| { + let a_default = fa == "index.html"; + let b_default = fb == "index.html"; + b_default.cmp(&a_default).then(na.cmp(nb)) + }); + + // ── Render one log page per branch ──────────────────────────────────────── + for (short_name, filename, commits) in &branch_pages { + let truncated = commits.len() >= max_commits; + + let branch_nav: Vec<BranchNav> = nav_entries + .iter() + .map(|(bn, bf)| BranchNav { + short_name: bn.clone(), + filename: bf.clone(), + is_current: bn == short_name, + }) + .collect(); + + let mut ctx = Context::new(); + ctx.insert("project_name", &config.site.name); + ctx.insert("lang", &config.site.lang); + ctx.insert("clone_url", &clone_url); + ctx.insert("current_branch", short_name); + ctx.insert("branch_nav", &branch_nav); + ctx.insert("commits", commits); + ctx.insert("truncated", &truncated); + ctx.insert("root_path", "../"); + + let html = tera.render("git_log.html", &ctx).into_diagnostic()?; + tokio::fs::write(ui_dir.join(filename), html) + .await + .into_diagnostic()?; + } + + // ── Render refs page ────────────────────────────────────────────────────── + { + let mut ctx = Context::new(); + ctx.insert("project_name", &config.site.name); + ctx.insert("lang", &config.site.lang); + ctx.insert("clone_url", &clone_url); + ctx.insert("tags", &tags); + ctx.insert("branches", &ref_branches); + ctx.insert("root_path", "../"); + + let html = tera.render("git_refs.html", &ctx).into_diagnostic()?; + tokio::fs::write(ui_dir.join("refs.html"), html) + .await + .into_diagnostic()?; + } + + // ── Render per-commit detail pages ──────────────────────────────────────── + for commit_info in &unique_commits { + let changed_files = get_changed_files(&commit_info.hash).await?; + let has_browse = !commit_info.ref_badges.is_empty(); + + let mut ctx = Context::new(); + ctx.insert("project_name", &config.site.name); + ctx.insert("lang", &config.site.lang); + ctx.insert("clone_url", &clone_url); + ctx.insert("commit", commit_info); + ctx.insert("changed_files", &changed_files); + ctx.insert("has_browse", &has_browse); + ctx.insert("root_path", "../../"); + + let html = tera.render("git_commit.html", &ctx).into_diagnostic()?; + tokio::fs::write( + ui_dir + .join("commit") + .join(format!("{}.html", commit_info.hash)), + html, + ) + .await + .into_diagnostic()?; + } + + // ── Tree browser (browse/<hash>/) ───────────────────────────────────────── + if !browse_revisions.is_empty() { + let browse_dir = ui_dir.join("browse"); + tokio::fs::create_dir_all(&browse_dir) + .await + .into_diagnostic()?; + + let project_name = config.site.name.clone(); + let lang = config.site.lang.clone(); + let clone_url_browse = clone_url.clone(); + let theme_path = PathBuf::from(".abbaye").join("theme"); + let repo_path_browse = repo_path.clone(); + + tokio::task::spawn_blocking(move || { + build_browse_pages( + &browse_revisions, + &browse_dir, + &repo_path_browse, + &theme_path, + &project_name, + &lang, + &clone_url_browse, + ) + }) + .await + .into_diagnostic()??; + } + + Ok(()) +} + +pub fn generate_clone_command(config: &AbbayeConfig, git_cfg: &GitUiConfig) -> Option<String> { + let clone_url: Option<String> = git_cfg.clone_url.clone().or_else(|| { + config.site.base_url.as_ref().map(|base| { + format!( + "{}/repository.git {}", + base.trim_end_matches('/'), + if config.site.name.contains(" ") { + format!("'{}'", config.site.name) + } else { + config.site.name.clone() + } + ) + }) + }); + clone_url +} + +// ── Git data collection ─────────────────────────────────────────────────────── + +/// Collect all local branches and assign output filenames. +/// +/// The branch whose short name matches `default_branch` gets `"index.html"`. +/// Every other branch gets `"<sanitized-short-name>.html"` where `/` is +/// replaced by `-`. +/// +/// If no branch matches `default_branch`, the first branch (alphabetically) +/// receives `"index.html"` and a warning is emitted. +fn collect_branch_entries( + repo: &gix::Repository, + default_branch: &str, +) -> Result<Vec<BranchEntry>> { + let mut entries: Vec<BranchEntry> = Vec::new(); + + let refs_platform = repo.references().into_diagnostic()?; + for reference in refs_platform.all().into_diagnostic()? { + let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; + let name = reference.name().as_bstr().to_str_lossy().into_owned(); + + if !name.starts_with("refs/heads/") { + continue; + } + + let short_name = name.trim_start_matches("refs/heads/").to_string(); + let tip = match reference.peel_to_id() { + Ok(id) => id.detach(), + Err(_) => continue, + }; + + // Tentative filename; replaced for the default branch below. + let filename = format!("{}.html", short_name.replace('/', "-")); + entries.push(BranchEntry { + short_name, + filename, + tip, + }); + } + + // Stable, predictable page order. + entries.sort_by(|a, b| a.short_name.cmp(&b.short_name)); + + // Assign index.html to the configured default branch. + if let Some(e) = entries.iter_mut().find(|e| e.short_name == default_branch) { + e.filename = "index.html".to_string(); + } else if let Some(first) = entries.first_mut() { + warn!( + "git_ui: default branch '{}' not found; using '{}' as index.html", + default_branch, first.short_name + ); + first.filename = "index.html".to_string(); + } + + Ok(entries) +} + +/// Build a map from commit hash (hex string) to the ref badges pointing at it. +/// Tags come before branches within each entry; both are sorted alphabetically. +fn build_ref_labels(repo: &gix::Repository) -> Result<HashMap<String, Vec<RefBadge>>> { + let mut map: HashMap<String, Vec<RefBadge>> = HashMap::new(); + + let refs_platform = repo.references().into_diagnostic()?; + for reference in refs_platform.all().into_diagnostic()? { + let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; + let name = reference.name().as_bstr().to_str_lossy().into_owned(); + + if name == "HEAD" || name.starts_with("refs/remotes/") { + continue; + } + + let hash = match reference.peel_to_id() { + Ok(id) => id.to_string(), + Err(_) => continue, + }; + + let badge = if let Some(label) = name.strip_prefix("refs/tags/") { + RefBadge { + label: label.to_string(), + kind: RefBadgeKind::Tag, + } + } else if let Some(label) = name.strip_prefix("refs/heads/") { + RefBadge { + label: label.to_string(), + kind: RefBadgeKind::Branch, + } + } else { + continue; + }; + + map.entry(hash).or_default().push(badge); + } + + // Within each entry: tags first (Tag < Branch via Ord), then alphabetical. + for badges in map.values_mut() { + badges.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.label.cmp(&b.label))); + } + + Ok(map) +} + +/// Walk at most `max` commits reachable from `tip`, newest first. +fn collect_commits( + repo: &gix::Repository, + tip: gix::ObjectId, + max: usize, + ref_labels: &HashMap<String, Vec<RefBadge>>, +) -> Result<Vec<CommitInfo>> { + let walk = repo.rev_walk([tip]).all().into_diagnostic()?; + let mut commits = Vec::new(); + + for info in walk.take(max) { + let info = info.into_diagnostic()?; + let id = info.id; + + let object = repo.find_object(id).into_diagnostic()?; + let commit = object.into_commit(); + let decoded = commit.decode().into_diagnostic()?; + + let author = decoded.author().into_diagnostic()?; + let author_name = author.name.to_str_lossy().into_owned(); + let author_email = author.email.to_str_lossy().into_owned(); + let unix_secs: i64 = author + .time + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let dt: DateTime<Utc> = DateTime::from_timestamp(unix_secs, 0).unwrap_or_default(); + let date = dt.format("%Y-%m-%d").to_string(); + let date_iso = dt.to_rfc3339(); + let datetime_display = dt.format("%Y-%m-%d %H:%M UTC").to_string(); + + let raw_msg = decoded.message.to_str_lossy(); + let (subject, body) = parse_message(&raw_msg); + + let hash = id.to_string(); + let hash_short = hash[..7].to_string(); + + let parents = info + .parent_ids + .iter() + .map(|p| { + let h = p.to_string(); + let hs = h[..7].to_string(); + CommitParent { + hash: h, + hash_short: hs, + } + }) + .collect(); + + let ref_badges = ref_labels.get(&hash).cloned().unwrap_or_default(); + + commits.push(CommitInfo { + hash, + hash_short, + author_name, + author_email, + date, + date_iso, + datetime_display, + subject, + body, + parents, + ref_badges, + }); + } + + Ok(commits) +} + +/// Collect tags and branches for the refs overview page. +fn collect_refs(repo: &gix::Repository) -> Result<(Vec<RefInfo>, Vec<RefInfo>)> { + let mut tags: Vec<RefInfo> = Vec::new(); + let mut branches: Vec<RefInfo> = Vec::new(); + + let refs_platform = repo.references().into_diagnostic()?; + for reference in refs_platform.all().into_diagnostic()? { + let mut reference = reference.map_err(|e| miette::miette!("{e}"))?; + let name = reference.name().as_bstr().to_str_lossy().into_owned(); + + if name.starts_with("refs/remotes/") || name == "HEAD" { + continue; + } + + let hash = match reference.peel_to_id() { + Ok(id) => id.to_string(), + Err(_) => continue, + }; + let hash_short = hash[..7.min(hash.len())].to_string(); + + if name.starts_with("refs/tags/") { + let short_name = name.trim_start_matches("refs/tags/").to_string(); + tags.push(RefInfo { + name, + short_name, + hash, + hash_short, + }); + } else if name.starts_with("refs/heads/") { + let short_name = name.trim_start_matches("refs/heads/").to_string(); + branches.push(RefInfo { + name, + short_name, + hash, + hash_short, + }); + } + } + + // Tags: newest first (reverse-lexicographic ≈ version order for semver tags). + tags.sort_by(|a, b| b.name.cmp(&a.name)); + branches.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok((tags, branches)) +} + +// ── Changed files via git CLI ───────────────────────────────────────────────── + +async fn get_changed_files(commit_hash: &str) -> Result<Vec<ChangedFile>> { + let output = tokio::process::Command::new("git") + .args(["diff-tree", "-p", "--no-commit-id", "-r", commit_hash]) + .output() + .await + .into_diagnostic()?; + + if !output.status.success() { + return Ok(vec![]); + } + + Ok(parse_diff_output(&String::from_utf8_lossy(&output.stdout))) +} + +/// Parse the output of `git diff-tree -p` into per-file [`ChangedFile`] entries. +/// +/// Each file section starts with a `diff --git a/<path> b/<path>` line. +/// Status is inferred from subsequent mode/rename headers. +/// Diff line kinds are determined by their leading character, with header +/// lines (`--- `, `+++ `) distinguished from content lines (`-`, `+`) by +/// the mandatory space that follows the three-character marker. +fn parse_diff_output(text: &str) -> Vec<ChangedFile> { + let mut files: Vec<ChangedFile> = Vec::new(); + let mut cur_lines: Vec<DiffLine> = Vec::new(); + let mut cur_path = String::new(); + let mut cur_status = "modified"; + + let push_line = |lines: &mut Vec<DiffLine>, kind, content: &str| { + lines.push(DiffLine { + kind, + content: content.to_string(), + }); + }; + + for line in text.lines() { + if line.starts_with("diff --git ") { + if !cur_path.is_empty() { + files.push(ChangedFile { + path: cur_path.clone(), + status: cur_status.to_string(), + diff_lines: std::mem::take(&mut cur_lines), + }); + } + // Extract the destination path from the trailing " b/<path>" token. + // rsplit_once handles paths that contain spaces. + cur_path = line + .rsplit_once(" b/") + .map(|(_, p)| p.to_string()) + .unwrap_or_default(); + cur_status = "modified"; + push_line(&mut cur_lines, DiffLineKind::Header, line); + } else if line.starts_with("new file mode") { + cur_status = "added"; + push_line(&mut cur_lines, DiffLineKind::Header, line); + } else if line.starts_with("deleted file mode") { + cur_status = "deleted"; + push_line(&mut cur_lines, DiffLineKind::Header, line); + } else if line.starts_with("rename from") || line.starts_with("rename to") { + cur_status = "renamed"; + push_line(&mut cur_lines, DiffLineKind::Header, line); + } else if line.starts_with("similarity index") + || line.starts_with("copy from") + || line.starts_with("copy to") + || line.starts_with("index ") + || line.starts_with("--- ") // file header, not a removed line + || line.starts_with("+++ ") // file header, not an added line + || line.starts_with("Binary files") + || line.starts_with('\\') + { + push_line(&mut cur_lines, DiffLineKind::Header, line); + } else if line.starts_with("@@") { + push_line(&mut cur_lines, DiffLineKind::Hunk, line); + } else if line.starts_with('+') { + push_line(&mut cur_lines, DiffLineKind::Added, line); + } else if line.starts_with('-') { + push_line(&mut cur_lines, DiffLineKind::Removed, line); + } else { + push_line(&mut cur_lines, DiffLineKind::Context, line); + } + } + + if !cur_path.is_empty() { + files.push(ChangedFile { + path: cur_path, + status: cur_status.to_string(), + diff_lines: cur_lines, + }); + } + + files +} + +// ── Bare clone export ───────────────────────────────────────────────────────── + +async fn export_bare_clone(source: &Path, dest: &Path) -> Result<()> { + if dest.exists() { + tokio::fs::remove_dir_all(dest).await.into_diagnostic()?; + } + + let status = tokio::process::Command::new("git") + .arg("clone") + .arg("--bare") + .arg(source) + .arg(dest) + .status() + .await + .into_diagnostic()?; + + if !status.success() { + return Err(miette::miette!( + "git clone --bare failed with status {status}" + )); + } + + // Enable dumb HTTP transport so the bare repo is clonable over plain HTTPS. + let status = tokio::process::Command::new("git") + .arg("-C") + .arg(dest) + .arg("update-server-info") + .status() + .await + .into_diagnostic()?; + + if !status.success() { + warn!("git update-server-info failed; dumb HTTP cloning may not work"); + } + + Ok(()) +} + +// ── Tree browser ────────────────────────────────────────────────────────────────── + +/// Build the full static tree browser for every revision in `revisions`. +/// +/// Everything here is synchronous (gix + std::fs + syntect), intended to run +/// inside `tokio::task::spawn_blocking`. +fn build_browse_pages( + revisions: &[(String, gix::ObjectId)], + browse_dir: &Path, // public/repository/browse/ + repo_path: &Path, // for `git cat-file blob` + theme_path: &Path, // .abbaye/theme (theme overrides) + project_name: &str, + lang: &Option<String>, + clone_url: &Option<String>, +) -> Result<()> { + use syntect::highlighting::ThemeSet; + use syntect::parsing::SyntaxSet; + + let ss = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let theme = &ts.themes["InspiredGitHub"]; + + // Build a separate Tera instance for browse templates. + let mut tera = Tera::default(); + for (name, builtin) in [ + ("git_tree.html", TEMPLATE_GIT_TREE), + ("git_blob.html", TEMPLATE_GIT_BLOB), + ] { + let override_path = theme_path.join(format!("{name}.j2")); + if override_path.is_file() { + tera.add_template_file(&override_path, Some(name)) + .map_err(|e| miette::miette!("{e}"))?; + } else { + tera.add_raw_template(name, builtin) + .map_err(|e| miette::miette!("{e}"))?; + } + } + + for (hash, oid) in revisions { + let rev_dir = browse_dir.join(hash); + std::fs::create_dir_all(&rev_dir).into_diagnostic()?; + + // Resolve the commit's root tree. + let repo = gix::open(repo_path).into_diagnostic()?; + let commit_obj = repo.find_object(*oid).into_diagnostic()?.into_commit(); + let decoded = commit_obj.decode().into_diagnostic()?; + let tree_id = decoded.tree(); + + walk_tree_dir( + repo_path, + &repo, + tree_id, + "", + hash, + &rev_dir, + &tera, + project_name, + lang, + clone_url, + &ss, + theme, + )?; + } + + Ok(()) +} + +/// Recursively generate one `index.html` (directory listing) per tree and one +/// `<name>.html` per blob, rooted at `rev_dir`. +/// TODO: Fix clippy warning about too many arguments +#[allow(clippy::too_many_arguments)] +fn walk_tree_dir( + repo_path: &Path, + repo: &gix::Repository, + tree_id: gix::ObjectId, + dir_path: &str, // "" = root, "src", "src/utils" + commit_hash: &str, + rev_dir: &Path, // public/repository/browse/<hash>/ + tera: &Tera, + project_name: &str, + lang: &Option<String>, + clone_url: &Option<String>, + ss: &syntect::parsing::SyntaxSet, + theme: &syntect::highlighting::Theme, +) -> Result<()> { + let tree_obj = repo.find_object(tree_id).into_diagnostic()?.into_tree(); + let decoded = tree_obj.decode().into_diagnostic()?; + + // Depth = number of path components in dir_path. + let depth: usize = dir_path.split('/').filter(|s| !s.is_empty()).count(); + + // Public output directory for this tree's index.html. + let page_dir = if dir_path.is_empty() { + rev_dir.to_path_buf() + } else { + dir_path + .split('/') + .filter(|s| !s.is_empty()) + .fold(rev_dir.to_path_buf(), |p, c| p.join(c)) + }; + std::fs::create_dir_all(&page_dir).into_diagnostic()?; + + let mut entries: Vec<TreeEntry> = Vec::new(); + // Defer recursion until after the listing page is written. + let mut subdirs: Vec<(String, gix::ObjectId)> = Vec::new(); + let mut blobs: Vec<(String, gix::ObjectId)> = Vec::new(); + + for entry in decoded.entries.iter() { + let name = entry.filename.to_str_lossy().into_owned(); + let oid: gix::ObjectId = entry.oid.to_owned(); + + if entry.mode.is_tree() { + entries.push(TreeEntry { + url: format!("{name}/index.html"), + name: name.clone(), + kind: TreeEntryKind::Tree, + }); + subdirs.push((name, oid)); + } else { + // blob, executable blob, symlink, or submodule commit + entries.push(TreeEntry { + url: format!("{name}.html"), + name: name.clone(), + kind: TreeEntryKind::Blob, + }); + if !entry.mode.is_commit() { + // Skip submodule gitlinks (they have no blob content). + blobs.push((name, oid)); + } + } + } + + // Directories first, then files; both alphabetical. + entries.sort_by(|a, b| { + let a_tree = matches!(a.kind, TreeEntryKind::Tree); + let b_tree = matches!(b.kind, TreeEntryKind::Tree); + b_tree.cmp(&a_tree).then(a.name.cmp(&b.name)) + }); + + // root_path: how many levels up to reach the site root (public/). + // browse/<hash>/[subpath/] => 3 + depth levels up. + let root_path = "../".repeat(3 + depth); + // commit_url: link back to the commit detail page. + let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); + let breadcrumbs = make_crumbs(dir_path, false, None); + + let mut ctx = Context::new(); + ctx.insert("project_name", project_name); + ctx.insert("lang", lang); + ctx.insert("clone_url", clone_url); + ctx.insert("commit_hash", commit_hash); + ctx.insert("commit_hash_short", &commit_hash[..7]); + ctx.insert("commit_url", &commit_url); + ctx.insert("dir_path", dir_path); + ctx.insert("entries", &entries); + ctx.insert("breadcrumbs", &breadcrumbs); + ctx.insert("root_path", &root_path); + + let html = tera + .render("git_tree.html", &ctx) + .map_err(|e| miette::miette!("{e}"))?; + std::fs::write(page_dir.join("index.html"), html).into_diagnostic()?; + + // Recurse into subdirectories. + for (name, oid) in subdirs { + let child_path = if dir_path.is_empty() { + name + } else { + format!("{dir_path}/{name}") + }; + walk_tree_dir( + repo_path, + repo, + oid, + &child_path, + commit_hash, + rev_dir, + tera, + project_name, + lang, + clone_url, + ss, + theme, + )?; + } + + // Render blob pages. + for (name, oid) in blobs { + let file_path = if dir_path.is_empty() { + name.clone() + } else { + format!("{dir_path}/{name}") + }; + render_blob_page( + repo_path, + &name, + &file_path, + oid, + commit_hash, + depth, + &page_dir, + tera, + project_name, + lang, + clone_url, + ss, + theme, + )?; + } + + Ok(()) +} + +/// Write one syntax-highlighted blob page to `page_dir/<name>.html`. +#[allow(clippy::too_many_arguments)] +fn render_blob_page( + repo_path: &Path, + filename: &str, + file_path: &str, // full path from repo root, e.g. "src/main.rs" + oid: gix::ObjectId, + commit_hash: &str, + depth: usize, // number of directory components containing the file + page_dir: &Path, // output directory (same as the parent tree's page_dir) + tera: &Tera, + project_name: &str, + lang: &Option<String>, + clone_url: &Option<String>, + ss: &syntect::parsing::SyntaxSet, + theme: &syntect::highlighting::Theme, +) -> Result<()> { + const MAX_BLOB_BYTES: usize = 1024 * 1024; // 1 MiB + + // Read blob via `git cat-file blob <oid>` to avoid gix private-field access. + let data: Vec<u8> = std::process::Command::new("git") + .current_dir(repo_path) + .args(["cat-file", "blob", &oid.to_string()]) + .output() + .map(|o| o.stdout) + .unwrap_or_default(); + + let is_binary = data[..data.len().min(8192)].contains(&0u8); + let too_large = data.len() > MAX_BLOB_BYTES; + + let content_html: Option<String> = if is_binary || too_large || data.is_empty() { + None + } else { + let text = String::from_utf8_lossy(&data); + let ext = std::path::Path::new(filename) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let syntax = ss + .find_syntax_by_extension(ext) + .or_else(|| { + text.lines() + .next() + .and_then(|l| ss.find_syntax_by_first_line(l)) + }) + .unwrap_or_else(|| ss.find_syntax_plain_text()); + Some( + syntect::html::highlighted_html_for_string(&text, ss, syntax, theme) + .unwrap_or_else(|_| format!("<pre>{}</pre>", escape_html(&text))), + ) + }; + + let root_path = "../".repeat(3 + depth); + let commit_url = format!("{}commit/{commit_hash}.html", "../".repeat(depth + 2)); + let breadcrumbs = make_crumbs( + std::path::Path::new(file_path) + .parent() + .and_then(|p| p.to_str()) + .unwrap_or(""), + true, + Some(filename), + ); + + let mut ctx = Context::new(); + ctx.insert("project_name", project_name); + ctx.insert("lang", lang); + ctx.insert("clone_url", clone_url); + ctx.insert("commit_hash", commit_hash); + ctx.insert("commit_hash_short", &commit_hash[..7]); + ctx.insert("commit_url", &commit_url); + ctx.insert("file_path", file_path); + ctx.insert("filename", filename); + ctx.insert("breadcrumbs", &breadcrumbs); + ctx.insert("content_html", &content_html); + ctx.insert("is_binary", &is_binary); + ctx.insert("too_large", &too_large); + ctx.insert("size", &data.len()); + ctx.insert("root_path", &root_path); + + let html = tera + .render("git_blob.html", &ctx) + .map_err(|e| miette::miette!("{e}"))?; + std::fs::write(page_dir.join(format!("{filename}.html")), html).into_diagnostic()?; + + Ok(()) +} + +/// Build breadcrumb entries for a tree or blob page. +/// +/// `dir_path` is the path to the containing directory (e.g. `"src"` for +/// `src/main.rs`). `depth` is derived from `dir_path` internally. +fn make_crumbs(dir_path: &str, is_blob: bool, filename: Option<&str>) -> Vec<Crumb> { + let parts: Vec<&str> = dir_path.split('/').filter(|s| !s.is_empty()).collect(); + let depth = parts.len(); + let mut crumbs = Vec::new(); + + // Root crumb (“~”). + let root_url = if depth == 0 && !is_blob { + None // we ARE the root dir listing + } else { + Some(format!("{}index.html", "../".repeat(depth))) + }; + crumbs.push(Crumb { + name: "~".to_string(), + url: root_url, + }); + + // Intermediate directory crumbs. + for (i, &part) in parts.iter().enumerate() { + let is_last_and_tree = i == depth - 1 && !is_blob; + let url = if is_last_and_tree { + None // current directory + } else { + // levels_up = how many "../" to navigate from current location to this dir + let levels_up = depth - i - 1; + Some(format!("{}index.html", "../".repeat(levels_up))) + }; + crumbs.push(Crumb { + name: part.to_string(), + url, + }); + } + + // Filename crumb for blobs. + if is_blob { + if let Some(name) = filename { + crumbs.push(Crumb { + name: name.to_string(), + url: None, + }); + } + } + + crumbs +} + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/// Split a raw commit message into (subject, optional body). +fn parse_message(raw: &str) -> (String, Option<String>) { + if let Some(idx) = raw.find("\n\n") { + let subject = raw[..idx].trim().to_string(); + let body = raw[idx + 2..].trim().to_string(); + let body = if body.is_empty() { None } else { Some(body) }; + (subject, body) + } else { + (raw.trim().to_string(), None) + } +} -
modified src/main.rs
diff --git a/src/main.rs b/src/main.rs index a59108a..80e4c18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -143,6 +143,8 @@ pub mod changelog; mod cli; /// Handles the `abbaye.toml` configuration file. pub mod config; +/// Generates a static git web UI and clonable bare repository. +pub mod git_ui; /// Generates the site from the configuration and builds it. pub mod site; /// Self-update logic: fetches the release feed and replaces the binary when a newer version exists. @@ -259,6 +261,12 @@ async fn build_all() -> Result<()> { } loop_result?; + + // Build the git repository UI once, now that HEAD is restored. + if let Some(git_ui_cfg) = &base_config.git_ui { + git_ui::build_git_repository_ui(&base_config, git_ui_cfg).await?; + } + info!("Done. Built {total} version(s)."); Ok(()) } @@ -307,6 +315,7 @@ async fn main() -> Result<()> { changelog: ChangelogConfig { ..Default::default() }, + git_ui: None, }; let config_path = base_path.join("abbaye.toml"); let toml = toml::to_string_pretty(&abbaye_config).into_diagnostic()?; @@ -314,9 +323,25 @@ async fn main() -> Result<()> { .await .into_diagnostic()?; } - cli::CliCommand::Build => { + cli::CliCommand::Build { repository_only } => { let config = config::load_config()?; - site::build_site(config).await?; + if repository_only { + match &config.git_ui { + Some(git_ui_cfg) => { + git_ui::build_git_repository_ui(&config, git_ui_cfg).await?; + } + None => { + return Err(miette::miette!( + "--repository-only requires [git_ui] to be configured in abbaye.toml" + )); + } + } + } else { + site::build_site(config.clone()).await?; + if let Some(git_ui_cfg) = &config.git_ui { + git_ui::build_git_repository_ui(&config, git_ui_cfg).await?; + } + } } cli::CliCommand::BuildAll => { build_all().await?; @@ -363,6 +388,36 @@ async fn main() -> Result<()> { ) .await .into_diagnostic()?; + tokio::fs::write(theme_path.join("git_log.html.j2"), git_ui::TEMPLATE_GIT_LOG) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_commit.html.j2"), + git_ui::TEMPLATE_GIT_COMMIT, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_refs.html.j2"), + git_ui::TEMPLATE_GIT_REFS, + ) + .await + .into_diagnostic()?; + tokio::fs::write(theme_path.join("site.css"), site::SITE_CSS) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_tree.html.j2"), + git_ui::TEMPLATE_GIT_TREE, + ) + .await + .into_diagnostic()?; + tokio::fs::write( + theme_path.join("git_blob.html.j2"), + git_ui::TEMPLATE_GIT_BLOB, + ) + .await + .into_diagnostic()?; } } Ok(()) -
modified src/site.rs
diff --git a/src/site.rs b/src/site.rs index 0a5eb57..a6e6141 100644 --- a/src/site.rs +++ b/src/site.rs @@ -25,6 +25,7 @@ use crate::{ pub const TEMPLATE_ROOT_INDEX: &str = include_str!("templates/root_index.html.j2"); pub const TEMPLATE_VERSION_INDEX: &str = include_str!("templates/version_index.html.j2"); +pub const SITE_CSS: &str = include_str!("templates/site.css"); const ATOM_FEED_FILENAME: &str = "releases.atom"; // ── Types ─────────────────────────────────────────────────────────────────── @@ -109,7 +110,17 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { tera.add_raw_template("version_index.html", TEMPLATE_VERSION_INDEX) .into_diagnostic()?; } - // If there's a `static` directory, copy it to the output directory, to improve user customization. + // Write shared CSS to static/ so the git UI templates can reference it. + { + let static_dir = output_dir.join("static"); + tokio::fs::create_dir_all(&static_dir) + .await + .into_diagnostic()?; + tokio::fs::write(static_dir.join("site.css"), SITE_CSS) + .await + .into_diagnostic()?; + } + // If there's a `static` directory in the theme, copy it over (may overwrite site.css). if theme_path.join("static").is_dir() { copy_dir_recursive(theme_path.join("static"), output_dir.join("static")).await?; } @@ -508,6 +519,26 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { }; // ── 8. Version index.html ───────────────────────────────────────────────── + + // Git UI integration: derive the clone URL once and compute the tag name + // for the current version so the templates can link into the repository UI. + let git_ui_clone_url: Option<String> = config.git_ui.as_ref().and_then(|cfg| { + cfg.clone_url.clone().or_else(|| { + config.site.base_url.as_ref().map(|b| { + format!( + "{}/repository.git {}", + b.trim_end_matches('/'), + if config.site.name.contains(" ") { + format!("'{}'", config.site.name) + } else { + config.site.name.clone() + } + ) + }) + }) + }); + let version_tag = config.version_extractor.tag_name(&version); + let mut version_ctx = Context::new(); version_ctx.insert("config", &config); version_ctx.insert("project_name", &config.site.name); @@ -520,6 +551,9 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { version_ctx.insert("has_docs_tarball", &has_docs_tarball); version_ctx.insert("has_dist", &has_dist); version_ctx.insert("dist_files", &dist_file_infos); + version_ctx.insert("git_ui_enabled", &config.git_ui.is_some()); + version_ctx.insert("git_ui_clone_url", &git_ui_clone_url); + version_ctx.insert("version_tag", &version_tag); let version_html = tera .render("version_index.html", &version_ctx) @@ -554,6 +588,8 @@ pub async fn build_site(config: AbbayeConfig) -> Result<()> { root_ctx.insert("repo_url", &config.site.repo_url); root_ctx.insert("versions", &version_entries); root_ctx.insert("atom_feed", ATOM_FEED_FILENAME); + root_ctx.insert("git_ui_enabled", &config.git_ui.is_some()); + root_ctx.insert("git_ui_clone_url", &git_ui_clone_url); let root_html = tera .render("root_index.html", &root_ctx) -
added src/templates/git_blob.html.j2
diff --git a/src/templates/git_blob.html.j2 b/src/templates/git_blob.html.j2 new file mode 100644 index 0000000..ec77c10 --- /dev/null +++ b/src/templates/git_blob.html.j2 @@ -0,0 +1,38 @@ +<!doctype html> +<html lang="{{ lang | default(value='en') }}"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{{ project_name }} — {{ filename }}</title> + <link rel="stylesheet" href="{{ root_path }}static/site.css" /> +</head> +<body> + <header> + <a href="{{ root_path }}" class="site-title">{{ project_name }}</a> + <nav> + <a href="{{ root_path }}repository/index.html">Log</a> + <a href="{{ root_path }}repository/refs.html">Refs</a> + </nav> + </header> + <main> + <nav class="breadcrumb"> + {% for crumb in breadcrumbs %} + {% if not loop.last %}<span class="breadcrumb-sep">/</span>{% endif %} + {% if crumb.url %}<a href="{{ crumb.url }}">{{ crumb.name | escape }}</a>{% else %}<span>{{ crumb.name | escape }}</span>{% endif %} + {% endfor %} + </nav> + + <p class="tree-rev">at <a class="hash" href="{{ commit_url }}">{{ commit_hash_short }}</a></p> + + {% if is_binary %} + <p class="blob-notice">Binary file ({{ size }} bytes)</p> + {% elif too_large %} + <p class="blob-notice">File too large to display ({{ size }} bytes)</p> + {% elif content_html %} + <div class="blob-content">{{ content_html | safe }}</div> + {% else %} + <p class="blob-notice">Empty file</p> + {% endif %} + </main> +</body> +</html> -
added src/templates/git_commit.html.j2
diff --git a/src/templates/git_commit.html.j2 b/src/templates/git_commit.html.j2 new file mode 100644 index 0000000..246ab98 --- /dev/null +++ b/src/templates/git_commit.html.j2 @@ -0,0 +1,65 @@ +<!doctype html> +<html lang="{{ lang | default(value='en') }}"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{{ project_name }} — {{ commit.hash_short }}</title> + <link rel="stylesheet" href="{{ root_path }}static/site.css" /> +</head> +<body> + <header> + <a href="{{ root_path }}" class="site-title">{{ project_name }}</a> + <a href="../index.html">← Log</a> + <nav> + <a href="../index.html">Log</a> + <a href="../refs.html">Refs</a> + </nav> + </header> + <main> + <h2 class="section-heading">Commit{% if has_browse %} <a class="hash" href="../browse/{{ commit.hash }}/">browse files</a>{% endif %}</h2> + <div class="commit-meta"> + <dl> + <dt>Hash</dt> + <dd><span class="hash-full">{{ commit.hash }}</span></dd> + <dt>Author</dt> + <dd>{{ commit.author_name }} <{{ commit.author_email }}></dd> + <dt>Date</dt> + <dd><time datetime="{{ commit.date_iso }}">{{ commit.datetime_display }}</time></dd> + {% if commit.parents %} + <dt>Parent{% if commit.parents | length > 1 %}s{% endif %}</dt> + <dd> + {% for p in commit.parents %} + <a class="hash" href="{{ p.hash }}.html">{{ p.hash_short }}</a>{% if not loop.last %} {% endif %} + {% endfor %} + </dd> + {% endif %} + </dl> + </div> + <h2 class="section-heading">Message</h2> + <div class="commit-message">{{ commit.subject }}{% if commit.body %} + +{{ commit.body }}{% endif %}</div> + {% if changed_files %} + <h2 class="section-heading">Changed Files ({{ changed_files | length }})</h2> + <ul class="files-list"> + {% for f in changed_files %} + <li> + <details> + <summary> + <span class="badge badge-{{ f.status }}">{{ f.status }}</span> + <span class="file-path">{{ f.path }}</span> + </summary> + {% if f.diff_lines %} + <table class="diff-table"><tbody> + {% for line in f.diff_lines %}<tr class="diff-{{ line.kind }}"><td class="diff-cell">{{ line.content }}</td></tr> + {% endfor %} + </tbody></table> + {% endif %} + </details> + </li> + {% endfor %} + </ul> + {% endif %} + </main> +</body> +</html> -
added src/templates/git_log.html.j2
diff --git a/src/templates/git_log.html.j2 b/src/templates/git_log.html.j2 new file mode 100644 index 0000000..088ec5f --- /dev/null +++ b/src/templates/git_log.html.j2 @@ -0,0 +1,63 @@ +<!doctype html> +<html lang="{{ lang | default(value='en') }}"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{{ project_name }} — {{ current_branch }}</title> + <link rel="stylesheet" href="{{ root_path }}static/site.css" /> +</head> +<body> + <header> + <a href="{{ root_path }}" class="site-title">{{ project_name }}</a> + <nav> + <a href="index.html" class="active">Log</a> + <a href="refs.html">Refs</a> + </nav> + </header> + <main> + {% if clone_url %} + <div class="clone-box"> + <span class="clone-label">Clone</span> + <span class="clone-url">git clone {{ clone_url }}</span> + </div> + {% endif %} + + {% if branch_nav | length > 1 %} + <div class="branch-switcher"> + {% for b in branch_nav %} + <a href="{{ b.filename }}"{% if b.is_current %} class="active"{% endif %}>{{ b.short_name }}</a> + {% endfor %} + </div> + {% endif %} + + <h2 class="section-heading">{{ current_branch }}</h2> + <table> + <thead> + <tr> + <th>Hash</th> + <th>Subject</th> + <th>Author</th> + <th>Date</th> + </tr> + </thead> + <tbody> + {% for commit in commits %} + <tr> + <td class="commit-hash-cell"> + <a class="hash" href="commit/{{ commit.hash }}.html">{{ commit.hash_short }}</a>{% for badge in commit.ref_badges %}<span class="ref-badge ref-badge-{{ badge.kind }}">{{ badge.label }}</span>{% endfor %} + </td> + <td>{{ commit.subject }}</td> + <td>{{ commit.author_name }}</td> + <td><time datetime="{{ commit.date_iso }}">{{ commit.date }}</time></td> + </tr> + {% endfor %} + </tbody> + </table> + {% if truncated %} + <p style="margin-top:1rem; font-size:0.85rem; color:var(--text-muted);"> + Showing the {{ commits | length }} most recent commits. + </p> + {% endif %} + </main> +</body> +</html> -
added src/templates/git_refs.html.j2
diff --git a/src/templates/git_refs.html.j2 b/src/templates/git_refs.html.j2 new file mode 100644 index 0000000..e4ff242 --- /dev/null +++ b/src/templates/git_refs.html.j2 @@ -0,0 +1,53 @@ +<!doctype html> +<html lang="{{ lang | default(value='en') }}"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{{ project_name }} — Refs</title> + <link rel="stylesheet" href="{{ root_path }}static/site.css" /> +</head> +<body> + <header> + <a href="{{ root_path }}" class="site-title">{{ project_name }}</a> + <nav> + <a href="index.html">Log</a> + <a href="refs.html" class="active">Refs</a> + </nav> + </header> + <main> + {% if tags %} + <h2 class="section-heading">Tags</h2> + <table> + <thead><tr><th>Tag</th><th>Hash</th></tr></thead> + <tbody> + {% for ref in tags %} + <tr id="tag-{{ ref.short_name }}"> + <td><span class="ref-kind-tag">{{ ref.short_name | escape }}</span></td> + <td><a class="hash" href="commit/{{ ref.hash }}.html">{{ ref.hash_short }}</a></td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + + {% if branches %} + <h2 class="section-heading">Branches</h2> + <table> + <thead><tr><th>Branch</th><th>Hash</th></tr></thead> + <tbody> + {% for ref in branches %} + <tr> + <td><span class="ref-kind-branch">{{ ref.short_name }}</span></td> + <td><a class="hash" href="commit/{{ ref.hash }}.html">{{ ref.hash_short }}</a></td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + + {% if not tags and not branches %} + <p style="color: var(--text-muted);">No refs found.</p> + {% endif %} + </main> +</body> +</html> -
added src/templates/git_tree.html.j2
diff --git a/src/templates/git_tree.html.j2 b/src/templates/git_tree.html.j2 new file mode 100644 index 0000000..27fe6fc --- /dev/null +++ b/src/templates/git_tree.html.j2 @@ -0,0 +1,45 @@ +<!doctype html> +<html lang="{{ lang | default(value='en') }}"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{{ project_name }} — {{ dir_path | default(value="~") }}</title> + <link rel="stylesheet" href="{{ root_path }}static/site.css" /> +</head> +<body> + <header> + <a href="{{ root_path }}" class="site-title">{{ project_name }}</a> + <nav> + <a href="{{ root_path }}repository/index.html">Log</a> + <a href="{{ root_path }}repository/refs.html">Refs</a> + </nav> + </header> + <main> + <nav class="breadcrumb"> + {% for crumb in breadcrumbs %} + {% if not loop.last %}<span class="breadcrumb-sep">/</span>{% endif %} + {% if crumb.url %}<a href="{{ crumb.url }}">{{ crumb.name | escape }}</a>{% else %}<span>{{ crumb.name | escape }}</span>{% endif %} + {% endfor %} + </nav> + + <table class="tree-table"> + <tbody> + {% for entry in entries %} + <tr> + <td class="tree-mode tree-mode-{{ entry.kind }}"></td> + <td> + {% if entry.kind == "tree" %} + <a class="tree-entry-tree" href="{{ entry.url }}">{{ entry.name | escape }}/</a> + {% else %} + <a class="tree-entry-blob" href="{{ entry.url }}">{{ entry.name | escape }}</a> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + + <p class="tree-rev">at <a class="hash" href="{{ commit_url }}">{{ commit_hash_short }}</a></p> + </main> +</body> +</html> -
modified src/templates/root_index.html.j2
diff --git a/src/templates/root_index.html.j2 b/src/templates/root_index.html.j2 index c91fbc0..647c66b 100644 --- a/src/templates/root_index.html.j2 +++ b/src/templates/root_index.html.j2 @@ -24,122 +24,7 @@ title="{{ project_name }} Releases" href="{{ atom_feed }}" /> - <style> - *, - *::before, - *::after { - box-sizing: border-box; - margin: 0; - padding: 0; - } - body { - font-family: - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; - background: #f5efe4; - color: #2e2416; - line-height: 1.6; - } - header { - background: #3d5732; - color: #f0e8d8; - padding: 1.5rem 2rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - } - header h1 { - font-size: 1.75rem; - font-weight: 700; - letter-spacing: 0.01em; - } - .feed-link { - display: flex; - align-items: center; - gap: 0.4em; - color: #f0e8d8; - text-decoration: none; - font-size: 0.85rem; - opacity: 0.8; - } - .feed-link:hover { - opacity: 1; - text-decoration: underline; - } - .feed-icon { - /* Classic orange RSS/Atom square */ - display: inline-block; - width: 1em; - height: 1em; - flex-shrink: 0; - background: #f96b15; - border-radius: 2px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='3' cy='13' r='2' fill='white'/%3E%3Cpath d='M3 6.5A6.5 6.5 0 0 1 9.5 13' stroke='white' stroke-width='2' fill='none' stroke-linecap='round'/%3E%3Cpath d='M3 2A11 11 0 0 1 14 13' stroke='white' stroke-width='2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center; - background-size: 80%; - } - - main { - max-width: 760px; - margin: 2.5rem auto; - padding: 0 1.5rem; - } - h2 { - font-size: 0.75rem; - font-weight: 700; - color: #7a6855; - text-transform: uppercase; - letter-spacing: 0.12em; - margin-bottom: 1rem; - } - ul { - list-style: none; - } - li { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - background: #fdfaf5; - border: 1px solid #c9baa8; - border-radius: 5px; - margin-bottom: 0.5rem; - } - li:hover { - border-color: #a06020; - } - a.version-link { - font-size: 1.05rem; - font-weight: 500; - color: #7a4429; - text-decoration: none; - } - a.version-link:hover { - text-decoration: underline; - color: #a05a3a; - } - .badge-latest { - font-size: 0.65rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - background: #a06020; - color: #fdf6ec; - padding: 0.2em 0.6em; - border-radius: 999px; - } - .version-date { - margin-left: auto; - font-size: 0.8rem; - color: #8a7060; - font-variant-numeric: tabular-nums; - } - </style> + <link rel="stylesheet" href="static/site.css" /> </head> <body> <header> @@ -149,9 +34,9 @@ Atom feed </a> </header> - <main> - <h2>Versions</h2> - <ul> + <main class="versions-main"> + <h2 class="section-heading">Versions</h2> + <ul class="versions-list"> {% for v in versions %} <li> <a class="version-link" href="{{ v.version }}/" @@ -166,6 +51,16 @@ </li> {% endfor %} </ul> + {% if git_ui_enabled %} + <h2 class="section-heading">Repository</h2> + {% if git_ui_clone_url %} + <div class="clone-box"> + <span class="clone-label">Clone</span> + <span class="clone-url">git clone {{ git_ui_clone_url }} {{ project_name }}</span> + </div> + {% endif %} + <p class="repo-browse-link"><a href="repository/">Browse source →</a></p> + {% endif %} </main> </body> </html> -
added src/templates/site.css
diff --git a/src/templates/site.css b/src/templates/site.css new file mode 100644 index 0000000..9afd057 --- /dev/null +++ b/src/templates/site.css @@ -0,0 +1,811 @@ +/* Abbaye shared stylesheet — used by the release pages and git repository UI. */ +:root { + --bg: #f5efe4; + --bg-content: #fdfaf5; + --bg-alt: #ede5d8; + --header-bg: #3d5732; + --header-fg: #f0e8d8; + --header-link: #c8bbaa; + --border: #c9baa8; + --text: #2e2416; + --text-muted: #7a6855; + --link: #7a4429; + --link-hover: #a05a3a; + --accent: #a06020; + --pre-bg: #eae2d4; + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +/* ── Header ─────────────────────────────────────────────────────── */ +header { + background: var(--header-bg); + color: var(--header-fg); + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + gap: 1.5rem; + flex-wrap: wrap; +} +header .site-title { + font-weight: 700; + font-size: 1rem; + color: var(--header-fg); + text-decoration: none; +} +header a { + color: var(--header-link); + text-decoration: none; + font-size: 0.9rem; +} +header a:hover { + text-decoration: underline; + color: var(--header-fg); +} +header nav { + display: flex; + gap: 1rem; + margin-left: auto; +} +header nav a { + color: var(--header-link); + font-size: 0.85rem; + padding: 0.2rem 0.5rem; + border-radius: 3px; +} +header nav a:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--header-fg); +} +header nav a.active { + color: var(--header-fg); + font-weight: 600; +} + +/* ── Layout ─────────────────────────────────────────────────────── */ +main { + max-width: 960px; + margin: 2rem auto; + padding: 0 1.5rem; +} + +/* ── Tables ─────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +thead th { + text-align: left; + padding: 0.5rem 0.75rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + border-bottom: 2px solid var(--border); +} +tbody tr { + border-bottom: 1px solid var(--border); +} +tbody tr:last-child { + border-bottom: none; +} +tbody tr:hover { + background: var(--bg-alt); +} +tbody td { + padding: 0.6rem 0.75rem; + vertical-align: middle; +} + +/* ── Monospace hash badges ───────────────────────────────────────── */ +.hash { + font-family: var(--font-mono); + font-size: 0.75rem; + background: var(--pre-bg); + border: 1px solid var(--border); + padding: 0.15em 0.5em; + border-radius: 3px; + color: var(--text); + text-decoration: none; + white-space: nowrap; +} +a.hash:hover { + border-color: var(--link); + color: var(--link); +} + +/* ── Status badges ───────────────────────────────────────────────── */ +.badge { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 0.2em 0.6em; + border-radius: 999px; + white-space: nowrap; +} +.badge-added { + background: #d4f0d4; + color: #2a6a2a; +} +.badge-deleted { + background: #f0d4d4; + color: #6a2a2a; +} +.badge-modified { + background: #f0e8d4; + color: #6a4a2a; +} +.badge-renamed { + background: #d4dff0; + color: #2a3a6a; +} +.badge-copied { + background: #e8d4f0; + color: #4a2a6a; +} +.badge-changed { + background: var(--pre-bg); + color: var(--text-muted); +} + +/* ── Clone instruction box ───────────────────────────────────────── */ +.clone-box { + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.clone-label { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + flex-shrink: 0; +} +.clone-url { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text); + flex: 1; + overflow-x: auto; +} + +/* ── Section headings ────────────────────────────────────────────── */ +.section-heading { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); + margin-bottom: 0.75rem; + margin-top: 2rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--border); +} +.section-heading:first-child { + margin-top: 0; +} + +/* ── Commit detail ───────────────────────────────────────────────── */ +.commit-meta { + background: var(--bg-content); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; + font-size: 0.875rem; +} +.commit-meta dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.3rem 1rem; +} +.commit-meta dt { + color: var(--text-muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + display: flex; + align-items: center; +} +.hash-full { + font-family: var(--font-mono); + font-size: 0.75rem; + word-break: break-all; +} +.commit-message { + background: var(--pre-bg); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1rem 1.25rem; + font-family: var(--font-mono); + font-size: 0.82rem; + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 1.5rem; +} + +/* ── Changed-files list ──────────────────────────────────────────── */ +.files-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.files-list li { + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} +.files-list details > summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + cursor: pointer; + list-style: none; + background: var(--bg-content); + user-select: none; +} +.files-list details > summary::-webkit-details-marker { + display: none; +} +.files-list details[open] > summary { + border-bottom: 1px solid var(--border); +} +.files-list details > summary:hover { + background: var(--bg-alt); +} +.file-path { + font-family: var(--font-mono); + font-size: 0.82rem; + flex: 1; +} + +/* ── Diff table ───────────────────────────────────────────────────── */ +.diff-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 0.78rem; + line-height: 1.45; +} +.diff-cell { + padding: 0 0.75em; + white-space: pre; + overflow-x: visible; +} +/* Suppress the default table styles from site.css */ +.diff-table thead th, +.diff-table tbody tr { + border-bottom: none; +} +.diff-table tbody tr:hover { + background: inherit; +} +.diff-table tbody tr:last-child { + border-bottom: none; +} + +tr.diff-header td { + color: var(--text-muted); +} +tr.diff-hunk td { + background: #eef2ff; + color: #2a3a7a; +} +tr.diff-added td { + background: #e8f8e8; + color: #1a4a1a; +} +tr.diff-removed td { + background: #fce8e8; + color: #5a1818; +} +tr.diff-context td { + color: var(--text); +} + +/* ── Ref badges (commit log) ────────────────────────────────────────────── */ +.commit-hash-cell { + white-space: nowrap; +} +.ref-badge { + display: inline-block; + font-size: 0.62rem; + font-weight: 600; + padding: 0.15em 0.45em; + border-radius: 3px; + border: 1px solid; + white-space: nowrap; + vertical-align: middle; + margin-left: 0.35em; + text-decoration: none; + line-height: 1.4; +} +.ref-badge-tag { + background: #e8f4e8; + color: #2d6a2d; + border-color: #a8ccaa; +} +.ref-badge-branch { + background: #e8eef8; + color: #2a3a6a; + border-color: #a8b8d8; +} + +/* ── Branch switcher (log page nav) ─────────────────────────────────────── */ +.branch-switcher { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 1.25rem; +} +.branch-switcher a { + font-size: 0.8rem; + padding: 0.25rem 0.75rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-content); + color: var(--link); + text-decoration: none; +} +.branch-switcher a:hover { + border-color: var(--link); + background: var(--bg-alt); + color: var(--link-hover); +} +.branch-switcher a.active { + background: var(--header-bg); + color: var(--header-fg); + border-color: var(--header-bg); +} + +/* ── Ref colours ─────────────────────────────────────────────────────────── */ +.ref-kind-tag { + color: var(--accent); +} +.ref-kind-branch { + color: #336699; +} + +/* ── Links ───────────────────────────────────────────────────────── */ +a { + color: var(--link); +} +a:hover { + color: var(--link-hover); +} + +/* ── Responsive ──────────────────────────────────────────────────── */ +@media (max-width: 640px) { + main { + padding: 0 1rem; + margin: 1rem auto; + } + .commit-meta dl { + grid-template-columns: 1fr; + } + .clone-box { + flex-direction: column; + align-items: flex-start; + } +} + +/* ════════════════════════════════════════════════════════════════════ + Root index (versions list) page + ════════════════════════════════════════════════════════════════════ */ + +header h1 { + font-size: 1.75rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +/* Atom/RSS feed link shown in the header */ +.feed-link { + display: flex; + align-items: center; + gap: 0.4em; + color: var(--header-link); + text-decoration: none; + font-size: 0.85rem; + opacity: 0.8; + margin-left: auto; +} +.feed-link:hover { + opacity: 1; + text-decoration: underline; +} +.feed-icon { + display: inline-block; + width: 1em; + height: 1em; + flex-shrink: 0; + background: #f96b15; + border-radius: 2px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='3' cy='13' r='2' fill='white'/%3E%3Cpath d='M3 6.5A6.5 6.5 0 0 1 9.5 13' stroke='white' stroke-width='2' fill='none' stroke-linecap='round'/%3E%3Cpath d='M3 2A11 11 0 0 1 14 13' stroke='white' stroke-width='2' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 80%; +} + +/* Versions list main area */ +.versions-main { + max-width: 760px; + margin: 2.5rem auto; + padding: 0 1.5rem; +} +.versions-list { + list-style: none; +} +.versions-list li { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--bg-content); + border: 1px solid var(--border); + border-radius: 5px; + margin-bottom: 0.5rem; +} +.versions-list li:hover { + border-color: var(--accent); +} +a.version-link { + font-size: 1.05rem; + font-weight: 500; + color: var(--link); + text-decoration: none; +} +a.version-link:hover { + text-decoration: underline; + color: var(--link-hover); +} +.badge-latest { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; + background: var(--accent); + color: #fdf6ec; + padding: 0.2em 0.6em; + border-radius: 999px; +} +.badge-latest a { + color: inherit; + text-decoration: none; +} +.badge-latest a:hover { + text-decoration: underline; +} +.version-date { + margin-left: auto; + font-size: 0.8rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.repo-browse-link { + margin-top: 0.75rem; + font-size: 0.9rem; +} + +/* ════════════════════════════════════════════════════════════════════ + Version release page + ════════════════════════════════════════════════════════════════════ */ + +header .title { + font-weight: 600; + font-size: 1rem; +} + +.release-layout { + display: flex; + min-height: calc(100vh - 44px); +} + +aside { + width: 230px; + flex-shrink: 0; + background: var(--bg-alt); + border-right: 1px solid var(--border); + padding: 1.5rem 1rem; + font-size: 0.875rem; +} +aside h3 { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--text-muted); + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} +aside h3:first-child { + margin-top: 0; +} +aside ul { + list-style: none; +} +aside li { + padding: 0.25rem 0; +} +aside a { + color: var(--link); + text-decoration: none; +} +aside a:hover { + text-decoration: underline; + color: var(--link-hover); +} + +.release-content { + flex: 1; + padding: 2rem 2.5rem; + max-width: 860px; + overflow-x: hidden; + background: var(--bg-content); + margin: 0; /* override the generic main centering */ +} + +/* Markdown typography inside the release content pane */ +.release-content h1, +.release-content h2, +.release-content h3, +.release-content h4, +.release-content h5, +.release-content h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.3; + color: var(--text); +} +.release-content h1 { + font-size: 1.9rem; + border-bottom: 2px solid var(--border); + padding-bottom: 0.4rem; +} +.release-content h2 { + font-size: 1.4rem; + border-bottom: 1px solid #ddd4c4; + padding-bottom: 0.3rem; +} +.release-content h3 { + font-size: 1.15rem; +} +.release-content p { + margin-bottom: 1em; +} +.release-content a { + color: var(--link); +} +.release-content a:hover { + color: var(--link-hover); +} +.release-content img { + max-width: 100%; + height: auto; +} +.release-content pre { + background: var(--pre-bg); + border: 1px solid var(--border); + border-radius: 5px; + padding: 1em 1.25em; + overflow-x: auto; + margin-bottom: 1em; + font-size: 0.875em; +} +.release-content code { + font-family: var(--font-mono); + background: var(--pre-bg); + padding: 0.15em 0.4em; + border-radius: 3px; + font-size: 0.875em; +} +.release-content pre code { + background: none; + padding: 0; + font-size: inherit; +} +.release-content ul, +.release-content ol { + padding-left: 1.5em; + margin-bottom: 1em; +} +.release-content li { + margin-bottom: 0.2em; +} +.release-content table { + border-collapse: collapse; + margin-bottom: 1em; + width: 100%; +} +.release-content th, +.release-content td { + border: 1px solid var(--border); + padding: 0.4em 0.75em; + text-align: left; +} +.release-content th { + background: var(--bg-alt); + font-weight: 600; +} +.release-content blockquote { + border-left: 4px solid var(--border); + padding: 0.5em 1em; + margin: 0 0 1em; + color: var(--text-muted); + font-style: italic; +} + +/* Dist file metadata in the sidebar */ +.dist-meta { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.15rem; + word-break: break-all; +} +.dist-meta code { + font-family: var(--font-mono); + font-size: 0.7rem; +} + +/* Changelog section divider and heading */ +.changelog-divider { + border: none; + border-top: 2px solid var(--border); + margin: 2.5rem 0; +} +.changelog-heading { + font-size: 1.3rem; + font-weight: 600; + color: #5a4030; + margin-bottom: 1rem; +} + +@media (max-width: 640px) { + .release-layout { + flex-direction: column; + } + aside { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--border); + } + .release-content { + padding: 1.5rem 1rem; + } +} + +/* ════════════════════════════════════════════════════════════════════ + Tree browser (directory listings and file views) + ════════════════════════════════════════════════════════════════════ */ + +/* ── Breadcrumb ───────────────────────────────────────────────────── */ +.breadcrumb { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 1rem; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.2em; +} +.breadcrumb a { + color: var(--link); + text-decoration: none; +} +.breadcrumb a:hover { + text-decoration: underline; +} +.breadcrumb-sep { + color: var(--border); + margin: 0 0.1em; + user-select: none; +} + +/* ── Revision note ────────────────────────────────────────────────── */ +.tree-rev { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.5rem; +} + +/* ── Directory listing table ──────────────────────────────────────── */ +.tree-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} +/* Suppress default table styles */ +.tree-table thead th, +.tree-table tbody tr { + border-bottom: 1px solid var(--border); +} +.tree-table tbody tr:last-child { + border-bottom: none; +} +.tree-table tbody tr:hover { + background: var(--bg-alt); +} + +.tree-mode { + width: 1.5rem; + padding: 0.45rem 0.5rem 0.45rem 0.75rem; + color: var(--text-muted); + font-size: 0.75rem; +} +/* Unicode icon via ::before, no JS needed */ +.tree-mode-tree::before { + content: "📁"; +} +.tree-mode-blob::before { + content: "📄"; +} + +.tree-table td:last-child { + padding: 0.45rem 0.75rem; +} +a.tree-entry-tree { + color: var(--link); + font-weight: 500; + text-decoration: none; +} +a.tree-entry-tree:hover { + text-decoration: underline; +} +a.tree-entry-blob { + color: var(--text); + text-decoration: none; +} +a.tree-entry-blob:hover { + color: var(--link); + text-decoration: underline; +} + +/* ── Blob (file content) ──────────────────────────────────────────── */ +.blob-notice { + font-size: 0.875rem; + color: var(--text-muted); + padding: 1.5rem 0; +} +.blob-content { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.78rem; + line-height: 1.5; +} +/* Override syntect's inline background-color on <pre> */ +.blob-content pre { + background-color: var(--pre-bg) !important; + margin: 0 !important; + padding: 0.75em 1em !important; + border-radius: 0; + font-family: var(--font-mono); + overflow-x: auto; +} -
modified src/templates/version_index.html.j2
diff --git a/src/templates/version_index.html.j2 b/src/templates/version_index.html.j2 index 61f7585..349de4d 100644 --- a/src/templates/version_index.html.j2 +++ b/src/templates/version_index.html.j2 @@ -18,234 +18,24 @@ {% endif %} {% endif %} <title>{{ project_name }} — {{ version }}</title> - <style> - *, - *::before, - *::after { - box-sizing: border-box; - margin: 0; - padding: 0; - } - body { - font-family: - system-ui, - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - sans-serif; - color: #2e2416; - background: #f5efe4; - line-height: 1.6; - } - /* ── Header ── */ - header { - background: #3d5732; - color: #f0e8d8; - padding: 0.75rem 1.5rem; - display: flex; - align-items: center; - gap: 1.5rem; - } - header a { - color: #c8bbaa; - text-decoration: none; - font-size: 0.9rem; - } - header a:hover { - text-decoration: underline; - color: #f0e8d8; - } - header .title { - font-weight: 600; - font-size: 1rem; - } - /* ── Layout ── */ - .layout { - display: flex; - min-height: calc(100vh - 44px); - } - /* ── Sidebar ── */ - aside { - width: 230px; - flex-shrink: 0; - background: #ede5d8; - border-right: 1px solid #c9baa8; - padding: 1.5rem 1rem; - font-size: 0.875rem; - } - aside h3 { - font-size: 0.65rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.12em; - color: #7a6855; - margin-top: 1.5rem; - margin-bottom: 0.5rem; - } - aside h3:first-child { - margin-top: 0; - } - aside ul { - list-style: none; - } - aside li { - padding: 0.25rem 0; - } - aside a { - color: #7a4429; - text-decoration: none; - } - aside a:hover { - text-decoration: underline; - color: #a05a3a; - } - /* ── Main content ── */ - main { - flex: 1; - padding: 2rem 2.5rem; - max-width: 860px; - overflow-x: hidden; - background: #fdfaf5; - } - /* ── Markdown typography ── */ - main h1, - main h2, - main h3, - main h4, - main h5, - main h6 { - margin-top: 1.5em; - margin-bottom: 0.5em; - line-height: 1.3; - color: #2e2416; - } - main h1 { - font-size: 1.9rem; - border-bottom: 2px solid #c9baa8; - padding-bottom: 0.4rem; - } - main h2 { - font-size: 1.4rem; - border-bottom: 1px solid #ddd4c4; - padding-bottom: 0.3rem; - } - main h3 { - font-size: 1.15rem; - } - main p { - margin-bottom: 1em; - } - main a { - color: #7a4429; - } - main a:hover { - color: #a05a3a; - } - main img { - max-width: 100%; - height: auto; - } - main pre { - background: #eae2d4; - border: 1px solid #c9baa8; - border-radius: 5px; - padding: 1em 1.25em; - overflow-x: auto; - margin-bottom: 1em; - font-size: 0.875em; - } - main code { - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, - monospace; - background: #eae2d4; - padding: 0.15em 0.4em; - border-radius: 3px; - font-size: 0.875em; - } - main pre code { - background: none; - padding: 0; - font-size: inherit; - } - main ul, - main ol { - padding-left: 1.5em; - margin-bottom: 1em; - } - main li { - margin-bottom: 0.2em; - } - main table { - border-collapse: collapse; - margin-bottom: 1em; - width: 100%; - } - main th, - main td { - border: 1px solid #c9baa8; - padding: 0.4em 0.75em; - text-align: left; - } - main th { - background: #ede5d8; - font-weight: 600; - } - main blockquote { - border-left: 4px solid #c9baa8; - padding: 0.5em 1em; - margin: 0 0 1em; - color: #7a6855; - font-style: italic; - } - /* ── Dist file metadata ── */ - .dist-meta { - font-size: 0.75rem; - color: #7a6855; - margin-top: 0.15rem; - word-break: break-all; - } - .dist-meta code { - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, - monospace; - font-size: 0.7rem; - } - /* ── Changelog section ── */ - .changelog-divider { - border: none; - border-top: 2px solid #c9baa8; - margin: 2.5rem 0; - } - .changelog-heading { - font-size: 1.3rem; - font-weight: 600; - color: #5a4030; - margin-bottom: 1rem; - } - /* ── Responsive ── */ - @media (max-width: 640px) { - .layout { - flex-direction: column; - } - aside { - width: 100%; - border-right: none; - border-bottom: 1px solid #c9baa8; - } - } - </style> + <link rel="stylesheet" href="../static/site.css" /> </head> <body> <header> <a href="../">← All versions</a> <span class="title">{{ project_name }} — {{ version }}</span> </header> - <div class="layout"> + <div class="release-layout"> <aside> {% if repo_url %} <h3>Repository</h3> <p><a href="{{ repo_url }}">{{ repo_url }}</a></p> + {% endif %} + {% if git_ui_enabled %} + <h3>Source</h3> + <ul> + <li><a href="../repository/refs.html#tag-{{ version_tag }}">Browse {{ version_tag }}</a></li> + </ul> {% endif %} {% if has_docs %} <h3>Documentation</h3> <ul> @@ -270,7 +60,7 @@ </ul> {% endif %} </aside> - <main> + <main class="release-content"> {{ readme_html | safe }} {% if changelog_html %} <hr class="changelog-divider" /> <h2 class="changelog-heading">Changes in {{ version }}</h2>