forks/sorcery
git clone https://git.gae.moe/forks/sorcery
2688dec
darwin-flake
1use std:: collections:: BTreeMap ; 2use std:: fmt:: Write as _; 3use std:: fs; 4use std:: path::{ Path , PathBuf }; 5use std:: sync:: Arc ; 6use std:: sync:: atomic::{ AtomicBool , AtomicUsize , Ordering }; 7 8use anyhow::{ Context , Result }; 9// Safe for text and double-quoted attribute contexts. 10use html_escape:: encode_double_quoted_attributeas escape; 11use percent_encoding::{ AsciiSet , NON_ALPHANUMERIC , PercentEncode , percent_encode}; 12 13use crate :: catalog:: Repository ; 14use crate :: highlight::{ Cache as GrammarCache , Highlighter }; 15 16/// Blobs larger than this get a raw download instead of a rendered page. 17const MAX_RENDER_BYTES : u64 =1 <<20 ; 18 19/// Cooperative cancellation for background builds; set when a 20/// human-triggered build wants the machine instead. 21pub ( crate ) type BuildCancel =Arc < AtomicBool >; 22 23pub ( crate ) fn bail_if_cancelled ( cancel : & Option < BuildCancel >) ->Result <()> { 24if cancel 25. as_ref () 26. is_some_and ( |flag| flag. load ( Ordering :: Relaxed )) 27{ 28 anyhow:: bail!( "site build cancelled" ); 29} 30Ok (()) 31} 32 33const PATH : & AsciiSet =& NON_ALPHANUMERIC 34. remove ( b'-' ) 35. remove ( b'.' ) 36. remove ( b'_' ) 37. remove ( b'~' ) 38. remove ( b'/' ); 39 40/// Percent-encodes a slash-separated path for use in a URI. 41pub ( crate ) fn encode_path ( path : & str ) ->PercentEncode < ' _ > { 42percent_encode ( path. as_bytes (), PATH ) 43} 44 45/// `tip` names the page's ref and the commit it points at; the client reads 46/// them from `<main data-ref data-tip>` to resolve hash routes relative to 47/// the page. 48fn page ( 49instance_name : & str , 50title : & str , 51description : & str , 52tip : Option <( & str , gix:: ObjectId )>, 53body : & str , 54) ->String { 55format! ( 56"<!doctype html>\n\ 57<html lang=\"en\">\n\ 58<head>\n\ 59<meta charset=\"utf-8\">\n\ 60<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\ 61<title>{title}</title>\n\ 62<meta name=\"description\" content=\"{description}\">\n\ 63<meta property=\"og:title\" content=\"{title}\">\n\ 64<meta property=\"og:description\" content=\"{description}\">\n\ 65<meta property=\"og:type\" content=\"website\">\n\ 66<meta property=\"og:site_name\" content=\"{instance_name}\">\n\ 67<link rel=\"stylesheet\" href=\"/css/style.css?v={format_version}\">\n\ 68<script type=\"module\" src=\"/js/main.js?v={format_version}\"></script>\n\ 69</head>\n\ 70<body>\n<main{tip}>\n{noscript}{body}</main>\n</body>\n\ 71</html>\n" , 72 instance_name =escape ( instance_name), 73 title =escape ( title), 74 description =escape ( description), 75 format_version =crate :: generate:: OUTPUT_FORMAT_VERSION , 76 tip = tip 77. map ( |( name, oid) |format! ( " data-ref=\"{}\" data-tip=\"{oid}\"" , escape ( name))) 78. unwrap_or_default (), 79// only repo pages lose anything to a missing client 80 noscript =if tip. is_some () { NOSCRIPT } else { "" }, 81) 82} 83 84const NOSCRIPT : & str =r#"<noscript> 85<div class="callout"> 86<p> 87hey! this is a static git repo viewer. commit diffs, file history, 88older commits and other revisions won't load without javascript. 89</p> 90<p> 91you are still able to view source files on branch tips, 92but the experience is degraded. 93</p> 94</div> 95</noscript> 96"# ; 97 98pub ( crate ) fn catalog_index ( instance_name : & str , repositories : & [ Repository ]) ->String { 99let mut body =String :: from ( "<h1>repositories</h1>\n" ); 100if repositories. is_empty () { 101 body. push_str ( "<p class=\"meta\">no repositories found</p>\n" ); 102} 103 104for reposin repositories. chunk_by ( |a, b| a. user == b. user ) { 105let user =& repos[ 0 ]. user ; 106let _ =writeln! ( 107 body, 108"<h2><a href=\"/{}/\">{}</a></h2>" , 109encode_path ( user), 110escape ( user), 111); 112catalog_list ( & mut body, repos); 113} 114 115let title =format! ( "repositories - {instance_name}" ); 116let description =format! ( "Git repositories hosted on {instance_name}." ); 117page ( instance_name, & title, & description, None , & body) 118} 119 120/// `repositories` must all belong to `user`. 121pub ( crate ) fn user_index ( instance_name : & str , user : & str , repositories : & [ Repository ]) ->String { 122let mut body =format! ( "<h1>{}</h1>\n" , escape ( user)); 123catalog_list ( & mut body, repositories); 124let title =format! ( "{user} - {instance_name}" ); 125let description =format! ( "Git repositories by {user} on {instance_name}." ); 126page ( instance_name, & title, & description, None , & body) 127} 128 129fn catalog_list ( body : & mut String , repositories : & [ Repository ]) { 130 body. push_str ( "<ul class=\"catalog\">\n" ); 131for repoin repositories{ 132let _ =writeln! ( 133 body, 134"<li><a href=\"/{user}/{repo}/\"><span class=\"repo-name\">{repo_name}</span> <span class=\"msg\">{description}</span>{updated}</a></li>" , 135 user =encode_path ( & repo. user ), 136 repo =encode_path ( & repo. name ), 137 repo_name =escape ( & repo. name ), 138 description =escape ( repo. description . as_deref (). unwrap_or_default ()), 139 updated = repo 140. updated 141. map ( |t|{ 142format! ( 143" <time datetime=\"{}\">{}</time>" , 144 t. format_or_unix ( gix:: date:: time:: format:: ISO8601_STRICT ), 145 t. format_or_unix ( gix:: date:: time:: format:: SHORT ), 146) 147}) 148. unwrap_or_default (), 149); 150} 151 body. push_str ( "</ul>\n" ); 152} 153 154# [ derive ( Clone , Copy , Debug , PartialEq , Eq )] 155pub enum RefKind { 156Branch , 157Tag , 158} 159 160# [ derive ( Clone , Copy , PartialEq , Eq )] 161pub enum StaticMode { 162Highlighted , 163/// Blob pages carry escaped plain text; the client highlights them. 164Plain , 165} 166 167# [ derive ( Clone , PartialEq , Eq , serde :: Deserialize , serde :: Serialize )] 168pub ( crate ) struct BlobVersion { 169pub oid : String , 170pub mode : u16 , 171pub touched : String , 172} 173 174# [ derive ( Clone , Copy )] 175pub ( crate ) struct BlobReuse < ' a > { 176pub versions : &' a BTreeMap < String , BlobVersion >, 177pub previous : Option <( &' a Path , &' a BTreeMap < String , BlobVersion >)>, 178} 179 180pub struct Site < ' a > { 181pub repo : &' a gix:: Repository , 182pub instance_name : String , 183pub name : String , 184pub base_url : String , 185pub description : Option < String >, 186pub clone_url : Option < String >, 187} 188 189impl Site < ' _ > { 190/// Display names like `user/repo` shorten to the repo part in crumbs. 191fn crumb_name ( & self ) ->& str { 192self . name . rsplit ( '/' ). next (). unwrap_or ( & self . name ) 193} 194 195fn ref_href ( & self , tip : & RefTip ) ->String { 196match tip. static_mode { 197None =>format! ( "{}#{}" , self . base_url , tip. commit_id ), 198Some ( _) =>{ 199format! ( "{}ref/{}/" , self . base_url , encode_path ( & tip. name )) 200} 201} 202} 203 204/// Name, description and clone command; the same on every page of the repo. 205fn repo_header ( & self ) ->String { 206let mut html =format! ( "<header class=\"repo\"><div>\n<h1>{}</h1>\n" , escape ( & self . name )); 207if let Some ( desc) =& self . description { 208let _ =writeln! ( html, "<p class=\"desc\">{}</p>" , escape ( desc)); 209} 210 html. push_str ( "</div>\n" ); 211if let Some ( url) =& self . clone_url { 212let _ =writeln! ( html, "<pre class=\"clone\">git clone {}</pre>" , escape ( url)); 213} 214 html. push_str ( "</header>\n" ); 215 html 216} 217} 218 219/// Author, summary, short sha and date of one commit, as a status line. 220fn commit_panel ( repo : & gix:: Repository , oid : gix:: ObjectId ) ->String { 221let Ok ( commit) = repo. find_object ( oid). map ( |o| o. try_into_commit ()) else { 222return String :: new (); 223}; 224let Ok ( commit) = commitelse { 225return String :: new (); 226}; 227let summary = commit. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (); 228let author = commit. author (). map ( |a| a. name . to_string ()). unwrap_or_default (); 229format! ( 230"<p class=\"snapshot-commit\">\ 231<span class=\"commit-author\">{author}</span>\ 232<span class=\"commit-message\" data-commit=\"{oid}\">{summary}</span>\ 233<span class=\"snapshot-sha sha\" data-commit=\"{oid}\">{short}</span>\ 234{time}</p>\n" , 235 author =escape ( & author), 236 summary =escape ( & summary), 237 short = commit. id (). shorten_or_id (), 238 time =time_of ( & commit), 239) 240} 241 242pub struct RefTip { 243pub kind : RefKind , 244pub name : String , 245pub commit_id : gix:: ObjectId , 246pub static_mode : Option < StaticMode >, 247} 248 249/// All local branches and tags, peeled to commits. Refs that don't peel to a 250/// commit (e.g. tags of blobs) are skipped. 251pub fn list_refs ( repo : & gix:: Repository ) ->Result < Vec < RefTip >> { 252let platform = repo. references () ?; 253let mut tips: Vec < RefTip > =Vec :: new (); 254for ( kind, iter) in [ 255( RefKind :: Branch , platform. local_branches () ?), 256( RefKind :: Tag , platform. tags () ?), 257] { 258for rin iter{ 259let mut r = r. map_err ( |e| anyhow:: anyhow!( "failed to iterate refs: {e}" )) ?; 260let name = r. name (). shorten (). to_string (); 261// A branch and tag may share a short name. The flat /ref/<name> 262// namespace gives the branch precedence. 263if kind ==RefKind :: Tag 264 && tips. iter (). any ( |t| t. kind ==RefKind :: Branch && t. name == name) 265{ 266continue ; 267} 268if let Ok ( commit) = r. peel_to_commit () { 269 tips. push ( RefTip { 270 kind, 271 name, 272commit_id : commit. id , 273static_mode : None , 274}); 275} 276} 277} 278Ok ( tips) 279} 280 281/// The branch HEAD points at, if it exists among `tips` (falling back to the 282/// first branch, matching forge behaviour for detached/unborn HEADs). 283pub fn head_tip < ' a >( repo : & gix:: Repository , tips : &' a [ RefTip ]) ->Option < &' a RefTip > { 284let head = repo. head_name (). ok (). flatten (). map ( |n| n. shorten (). to_string ()); 285 tips. iter () 286. find ( |t| t. kind ==RefKind :: Branch &&Some ( & t. name ) == head. as_ref ()) 287. or_else ( || tips. iter (). find ( |t| t. kind ==RefKind :: Branch )) 288} 289 290pub fn write_file ( path : & Path , contents : impl AsRef <[ u8 ]>) ->Result <()> { 291if let Some ( parent) = path. parent () { 292 fs:: create_dir_all ( parent) ?; 293} 294 fs:: write ( path, contents). with_context ( ||format! ( "writing {}" , path. display ())) 295} 296 297/// Renders the full static view of one ref (tree pages, blob pages, raw files 298/// for binaries) into `out`, which is typically a staging dir later swapped 299/// into place at `<site>/ref/<name>/`. 300pub fn render_ref ( 301site : & Site , 302tips : & [ RefTip ], 303tip : & RefTip , 304commit : & gix:: Commit , 305out : & Path , 306reuse : Option < BlobReuse < ' _ >>, 307highlights : Arc < GrammarCache >, 308cancel : Option < BuildCancel >, 309) ->Result <()> { 310let mut r =RefRenderer :: new ( site, tips, tip, commit, out, highlights. clone (), cancel. clone ()); 311let tree = commit. tree () ?; 312let mut blobs =Vec :: new (); 313 r. walk ( & tree, & mut Vec :: new (), & mut blobs) ?; 314 315// Blob pages dominate build time and are independent of each other, so 316// fan out; the repository is only ever read, so each worker gets its own 317// thread-local handle and reusable highlighter. Page costs vary wildly 318// (and cluster by directory), so workers pull from a shared counter 319// rather than taking fixed shares. 320let ctx =BlobContext { 321instance_name : site. instance_name . clone (), 322name : site. name . clone (), 323base_url : site. base_url . clone (), 324urlbase : r. urlbase . clone (), 325repo_header : site. repo_header (), 326label : tip. name . clone (), 327tip_id : commit. id , 328highlight : tip. static_mode !=Some ( StaticMode :: Plain ), 329out : out. to_owned (), 330 reuse, 331 highlights, 332 cancel, 333}; 334let repo = site. repo . clone (). into_sync (); 335let next =AtomicUsize :: new ( 0 ); 336 rayon:: broadcast ( |_|{ 337let repo = repo. to_thread_local (); 338let mut highlighter =Highlighter :: new ( ctx. highlights . clone ()); 339while let Some ( job) = blobs. get ( next. fetch_add ( 1 , Ordering :: Relaxed )) { 340if let Err ( error) =bail_if_cancelled ( & ctx. cancel ) 341. and_then ( |() |blob_page ( & ctx, & repo, & mut highlighter, job)) 342{ 343 next. store ( blobs. len (), Ordering :: Relaxed ); 344return Err ( error); 345} 346} 347Ok (()) 348}) 349. into_iter () 350. collect () 351} 352 353/// Everything a blob page needs besides the per-worker repository handle and 354/// highlighter. 355struct BlobContext < ' a > { 356instance_name : String , 357name : String , 358base_url : String , 359urlbase : String , 360repo_header : String , 361label : String , 362tip_id : gix:: ObjectId , 363highlight : bool , 364out : PathBuf , 365reuse : Option < BlobReuse < ' a >>, 366highlights : Arc < GrammarCache >, 367cancel : Option < BuildCancel >, 368} 369 370struct BlobJob { 371path : Vec < String >, 372oid : gix:: ObjectId , 373is_link : bool , 374/// Switcher and crumbs, prebuilt during the walk: they need ref-wide 375/// state that the workers don't carry. 376chrome : String , 377} 378 379fn blob_page ( 380ctx : & BlobContext , 381repo : & gix:: Repository , 382hl : & mut Highlighter , 383job : & BlobJob , 384) ->Result <()> { 385let joined = job. path . join ( "/" ); 386let rel =format! ( "blob/{joined}" ); 387// Embedded images may change without the markdown blob changing. 388if !is_markdown ( & joined) 389 &&let Some ( reuse) = ctx. reuse 390 &&let Some (( previous, previous_versions)) = reuse. previous 391 &&reuse_blob ( 392 previous, 393 previous_versions, 394 reuse. versions , 395& joined, 396& ctx. out , 397) 398{ 399return Ok (()); 400} 401let data =& repo. find_object ( job. oid ) ?. data . to_vec (); 402 403let size = data. len () as u64 ; 404let image = !job. is_link &&image_mime ( & joined). is_some (); 405let raw_url =format! ( "{}raw/{}/{}" , ctx. base_url , job. oid , encode_path ( & joined)); 406let looks_binary = data[ ..data. len (). min ( 8192 )]. contains ( & 0 ); 407let text =( !job. is_link && !image && !looks_binary && size <=MAX_RENDER_BYTES ) 408. then ( ||String :: from_utf8_lossy ( data)); 409let lines = text. as_ref (). map_or ( 0 , |text| text. lines (). count ()); 410let stats =if job. is_link { 411format! ( "<span>symlink \u{2192} {}</span>" , escape ( & String :: from_utf8_lossy ( data))) 412} else if text. is_some () { 413format! ( 414"<span>{}</span><span>{lines} line{}</span>" , 415human_size ( size), 416if lines ==1 { "" } else { "s" }, 417) 418} else { 419format! ( 420"<span>{}</span><span>{}</span>" , 421if image{ 422"image" 423} else if looks_binary{ 424"binary file" 425} else { 426"large file" 427}, 428human_size ( size), 429) 430}; 431let touched = ctx 432. reuse 433. and_then ( |reuse| reuse. versions . get ( & joined)) 434. and_then ( |version| gix:: ObjectId :: from_hex ( version. touched . as_bytes ()). ok ()) 435. unwrap_or ( ctx. tip_id ); 436let mut body =format! ( 437"{repo_header}{panel}<header class=\"topbar\">{chrome}<span class=\"view-stats\">{stats}</span>\ 438<span class=\"actions\"><a class=\"raw\" href=\"{raw_url}\">raw</a></span></header>\n" , 439 repo_header = ctx. repo_header , 440 panel =commit_panel ( repo, touched), 441 chrome = job. chrome , 442); 443 444if let Some ( text) = text{ 445let name = job. path . last (). expect ( "blob path is never empty" ); 446// Plain mode defers highlighting to the client, which detects the 447// language from the path carried in data-hl. 448let ( src_attrs, code) =if ctx. highlight { 449let lang =crate :: grammar_manifest:: detect ( name); 450let code = lang 451. and_then ( |lang| hl. highlight_lines ( lang, & text). ok ()) 452. unwrap_or_else ( || text. lines (). map ( |line|escape ( line). to_string ()). collect ()); 453let class = lang. map ( |l|format! ( " language-{l}" )). unwrap_or_default (); 454( format! ( "class=\"code src{class}\"" ), code) 455} else { 456( 457format! ( "class=\"code src\" data-hl=\"{}\"" , escape ( & joined)), 458 text. lines (). map ( |line|escape ( line). to_string ()). collect (), 459) 460}; 461let mut source =format! ( "<pre {src_attrs}>" ); 462let line_count = code. len (); 463for ( i, line) in code. into_iter (). enumerate () { 464let number = i +1 ; 465let _ =write! ( 466 source, 467"<span class=\"code-line\"><a class=\"ln\" id=\"L{number}\" href=\"#L{number}\">{number}</a><span class=\"code-text\">{line}</span>" 468); 469if number < line_count{ 470 source. push_str ( "<span class=\"code-break\">\n</span>" ); 471} 472 source. push_str ( "</span>" ); 473} 474 source. push_str ( "</pre>" ); 475if is_markdown ( name) { 476let urls =RelativeUrls { 477 repo, 478base_url : & ctx. base_url , 479urlbase : & ctx. urlbase , 480commit : ctx. tip_id , 481directory : & job. path [ ..job. path . len () -1 ], 482}; 483let rendered =markdown ( hl, & text, |url, image| urls. resolve ( url, image)); 484let _ =writeln! ( 485 body, 486"<div class=\"markdown-view\">\ 487<input type=\"radio\" name=\"markdown-view\" id=\"markdown-rendered\" checked>\ 488<label for=\"markdown-rendered\">rendered</label>\ 489<input type=\"radio\" name=\"markdown-view\" id=\"markdown-code\">\ 490<label for=\"markdown-code\">code</label>\ 491<section class=\"readme rendered\">{rendered}</section>\ 492<div class=\"code-panel\">{source}</div>\ 493</div>" , 494); 495} else { 496let _ =writeln! ( body, "{source}" ); 497} 498} else if image{ 499let _ =writeln! ( 500 body, 501"<div class=\"image-preview\"><img src=\"{}\" alt=\"{}\"></div>" , 502escape ( & raw_url), 503escape ( & joined) 504); 505} else if !job. is_link { 506let _ =writeln! ( 507 body, 508"<div class=\"file-download\"><p>This file cannot be previewed.</p><a href=\"{}\" download=\"{}\">download raw file</a></div>" , 509escape ( & raw_url), 510escape ( job. path . last (). expect ( "blob path is never empty" )) 511); 512} 513 514let title =format! ( "{} - {} @ {}" , joined, ctx. name , ctx. label ); 515let description =format! ( "{joined} in {} at {}." , ctx. name , ctx. label ); 516write_file ( 517& ctx. out . join ( & rel), 518page ( & ctx. instance_name , & title, & description, Some (( & ctx. label , ctx. tip_id )), & body), 519) 520} 521 522fn reuse_blob ( 523previous : & Path , 524previous_versions : & BTreeMap < String , BlobVersion >, 525versions : & BTreeMap < String , BlobVersion >, 526path : & str , 527out : & Path , 528) ->bool { 529let Some ( version) = versions. get ( path) else { 530return false ; 531}; 532if previous_versions. get ( path) !=Some ( version) { 533return false ; 534} 535let previous = previous. join ( "blob" ). join ( path); 536let out = out. join ( "blob" ). join ( path); 537if !previous. is_file () 538 || out. parent (). is_none_or ( |parent| fs:: create_dir_all ( parent). is_err ()) 539{ 540return false ; 541} 542if fs:: hard_link ( & previous, & out). is_ok () || fs:: copy ( & previous, & out). is_ok () { 543return true ; 544} 545let _ = fs:: remove_file ( out); 546false 547} 548 549/// The landing page: repo header plus the HEAD branch's root tree view, with 550/// entry links pointing into the branch's own pages under `ref/`. 551pub fn render_site_index ( 552site : & Site , 553tips : & [ RefTip ], 554out : & Path , 555highlights : Arc < GrammarCache >, 556cancel : Option < BuildCancel >, 557) ->Result <()> { 558let mut body =format! ( "<a class=\"back\" href=\"/\">← back</a>\n{}" , site. repo_header ()); 559 560let head =head_tip ( site. repo , tips); 561match head{ 562None => body. push_str ( "<p class=\"meta\">no branches yet</p>\n" ), 563Some ( tip) =>{ 564let commit = site. repo . find_object ( tip. commit_id ) ?. try_into_commit () ?; 565let tree = commit. tree () ?; 566let mut r =RefRenderer :: new ( site, tips, tip, & commit, out, highlights, cancel); 567let entries =collect_entries ( & tree) ?; 568 body. push_str ( & commit_panel ( site. repo , tip. commit_id )); 569 body. push_str ( & r. tree_topbar ( & [], & entries)); 570if tip. static_mode ==Some ( StaticMode :: Highlighted ) { 571 body. push_str ( & render_languages ( 572& crate :: languages:: analyze ( site. repo , & tree) ?, 573 tip. commit_id , 574)); 575} 576 body. push_str ( & r. overview ( & entries)); 577 body. push_str ( & r. readme_section ( & [], & entries)); 578} 579} 580 581let description = site 582. description 583. clone () 584. unwrap_or_else ( ||format! ( "{} repository on {}." , site. name , site. instance_name )); 585write_file ( 586& out. join ( "index.html" ), 587page ( & site. instance_name , & site. name , & description, head. map ( |tip|( tip. name . as_str (), tip. commit_id )), & body), 588) 589} 590 591/// `data-tip` and `data-language` let the client turn each label into a 592/// link to that language's files at the tip. 593fn render_languages ( stats : & [ crate :: languages:: Stat ], tip : gix:: ObjectId ) ->String { 594let total = stats. iter (). map ( |stat| stat. bytes ). sum ::< u64 >(); 595if total ==0 { 596return String :: new (); 597} 598 599let mut html =format! ( 600"<details class=\"languages\" data-tip=\"{tip}\">\n<summary><span class=\"language-label\">languages</span>" , 601); 602for statin stats{ 603let percentage = stat. bytes as f64 * 100.0 / totalas f64 ; 604let _ =write! ( 605 html, 606"<span title=\"{} {:.1}%\" style=\"background:{};width:{percentage:.6}%\"></span>" , 607escape ( stat. name ), 608 percentage, 609 stat. color , 610); 611} 612 html. push_str ( "</summary>\n<ul>\n" ); 613for statin stats{ 614let percentage = stat. bytes as f64 * 100.0 / totalas f64 ; 615let _ =writeln! ( 616 html, 617"<li data-language=\"{}\"><i style=\"background:{}\"></i><span>{} <small>{:.1}%</small></span></li>" , 618 stat. id , 619 stat. color , 620escape ( stat. name ), 621 percentage, 622); 623} 624 html. push_str ( "</ul>\n</details>\n" ); 625 html 626} 627 628/// `/refs`: branch and tag tables with tip summaries and dates. 629pub fn render_refs_page ( site : & Site , tips : & [ RefTip ], out : & Path ) ->Result <()> { 630let head =head_tip ( site. repo , tips); 631let mut body =format! ( 632"{}<header class=\"topbar\"><nav class=\"crumbs\"><a href=\"{}\">{}</a> / <span class=\"cur\">refs</span></nav></header>\n" , 633 site. repo_header (), 634 site. base_url , 635escape ( site. crumb_name ()), 636); 637 638for ( kind, heading) in [( RefKind :: Branch , "branches" ), ( RefKind :: Tag , "tags" )] { 639// HEAD branch first, then alphabetical; tags newest first 640let mut dated: Vec < _ > = tips 641. iter () 642. filter ( |t| t. kind == kind) 643. map ( |t|{ 644let commit = site 645. repo 646. find_object ( t. commit_id ) 647. map_err ( anyhow:: Error :: from) 648. and_then ( |o|Ok ( o. try_into_commit () ?)); 649let ( summary, time, secs) =match & commit{ 650Ok ( c) =>( 651 c. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (), 652time_of ( c), 653author_time ( c). map ( |t| t. seconds ). unwrap_or ( 0 ), 654), 655Err ( _) =>( String :: new (), String :: new (), 0 ), 656}; 657( t, summary, time, secs) 658}) 659. collect (); 660if dated. is_empty () { 661continue ; 662} 663match kind{ 664RefKind :: Branch => dated. sort_by ( |a, b|{ 665let is_head = |t : & RefTip | head. is_some_and ( |head| head. name == t. name ); 666is_head ( b. 0 ). cmp ( & is_head ( a. 0 )). then_with ( || a. 0 . name . cmp ( & b. 0 . name )) 667}), 668RefKind :: Tag => dated. sort_by_key ( |d| std:: cmp:: Reverse ( d. 3 )), 669} 670 671let _ =write! ( body, "<h2>{heading}</h2>\n<table class=\"list\">\n" ); 672for ( tip, summary, time, _) in dated{ 673let _ =writeln! ( 674 body, 675"<tr><td><a href=\"{href}\">{name}</a></td><td class=\"msg\">{summary}</td><td class=\"date\">{time}</td></tr>" , 676 href = site. ref_href ( tip), 677 name =escape ( & tip. name ), 678 summary =escape ( & summary), 679 time = time, 680); 681} 682 body. push_str ( "</table>\n" ); 683} 684 685let title =format! ( "refs - {}" , site. name ); 686let description =format! ( "Branches and tags for {}." , site. name ); 687write_file ( 688& out. join ( "refs" ), 689page ( & site. instance_name , & title, & description, head. map ( |tip|( tip. name . as_str (), tip. commit_id )), & body), 690) 691} 692 693struct RefRenderer < ' a > { 694site : &' a Site < ' a >, 695tips : &' a [ RefTip ], 696out : &' a Path , 697label : String , 698/// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`. 699urlbase : String , 700tip_id : gix:: ObjectId , 701hl : Highlighter , 702cancel : Option < BuildCancel >, 703} 704 705enum EntryKind { 706Dir , 707File , 708Link , 709Submodule , 710} 711 712type Entry =( String , EntryKind , gix:: ObjectId ); 713 714fn collect_entries ( tree : & gix:: Tree < ' _ >) ->Result < Vec < Entry >> { 715let mut entries =Vec :: new (); 716for entryin tree. iter () { 717let entry = entry?; 718let mode = entry. mode (); 719let kind =if mode. is_tree () { 720EntryKind :: Dir 721} else if mode. is_link () { 722EntryKind :: Link 723} else if mode. is_commit () { 724EntryKind :: Submodule 725} else { 726EntryKind :: File 727}; 728 entries. push (( entry. filename (). to_string (), kind, entry. oid (). to_owned ())); 729} 730 entries. sort_by ( |a, b|{ 731let rank = |k : & EntryKind | !matches! ( k, EntryKind :: Dir ); 732rank ( & a. 1 ). cmp ( & rank ( & b. 1 )). then_with ( || a. 0 . cmp ( & b. 0 )) 733}); 734Ok ( entries) 735} 736 737/// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone 738/// directories reads better as one link than as three clicks. 739fn collapse_lone_dirs ( repo : & gix:: Repository , name : & str , mut oid : gix:: ObjectId ) ->String { 740let mut path = name. to_string (); 741while let Ok ( tree) = repo. find_tree ( oid) { 742let mut entries = tree. iter (); 743match ( entries. next (), entries. next ()) { 744( Some ( Ok ( only)), None ) if only. mode (). is_tree () =>{ 745let _ =write! ( path, "/{}" , only. filename ()); 746 oid = only. oid (). to_owned (); 747} 748 _ =>break , 749} 750} 751 path 752} 753 754impl < ' a > RefRenderer < ' a > { 755fn new ( 756site : &' a Site < ' a >, 757tips : &' a [ RefTip ], 758tip : & RefTip , 759commit : & gix:: Commit , 760out : &' a Path , 761highlights : Arc < GrammarCache >, 762cancel : Option < BuildCancel >, 763) ->Self { 764RefRenderer { 765 site, 766 tips, 767 out, 768label : tip. name . clone (), 769urlbase : format! ( "{}ref/{}/" , site. base_url , encode_path ( & tip. name )), 770tip_id : commit. id , 771hl : Highlighter :: new ( highlights), 772 cancel, 773} 774} 775 776fn walk ( 777& mut self , 778tree : & gix:: Tree < ' _ >, 779path : & mut Vec < String >, 780blobs : & mut Vec < BlobJob >, 781) ->Result <()> { 782bail_if_cancelled ( & self . cancel ) ?; 783let entries =collect_entries ( tree) ?; 784self . tree_page ( path, & entries) ?; 785 786for ( name, kind, oid) in entries{ 787 path. push ( name); 788match kind{ 789EntryKind :: Dir =>{ 790let subtree =self . site . repo . find_object ( oid) ?. try_into_tree () ?; 791self . walk ( & subtree, path, blobs) ?; 792} 793EntryKind :: File |EntryKind :: Link =>{ 794 blobs. push ( BlobJob { 795chrome : format! ( "{}{}" , self . switcher (), self . crumbs ( path, true )), 796path : path. clone (), 797 oid, 798is_link : matches! ( kind, EntryKind :: Link ), 799}); 800} 801EntryKind :: Submodule =>{} 802} 803 path. pop (); 804} 805Ok (()) 806} 807 808fn tree_page ( & mut self , path : & [ String ], entries : & [ Entry ]) ->Result <()> { 809let rel =if path. is_empty () { 810"index.html" . to_string () 811} else { 812format! ( "tree/{}/index.html" , path. join ( "/" )) 813}; 814let mut body =if path. is_empty () { 815"<a class=\"back\" href=\"/\">← back</a>\n" . to_string () 816} else { 817String :: new () 818}; 819 body. push_str ( & self . site . repo_header ()); 820 body. push_str ( & commit_panel ( self . site . repo , self . tip_id )); 821 body. push_str ( & self . tree_topbar ( path, entries)); 822if path. is_empty () { 823 body. push_str ( & self . overview ( entries)); 824} else { 825 body. push_str ( "<h2 class=\"file-heading\">files</h2>\n" ); 826 body. push_str ( & self . listing ( path, entries)); 827} 828 body. push_str ( & self . readme_section ( path, entries)); 829 830let title =if path. is_empty () { 831format! ( "{} @ {}" , self . site . name , self . label ) 832} else { 833format! ( "{}/ - {} @ {}" , path. join ( "/" ), self . site . name , self . label ) 834}; 835let description =if path. is_empty () { 836format! ( "Source tree for {} at {}." , self . site . name , self . label ) 837} else { 838format! ( 839"{} in {} at {}." , 840 path. join ( "/" ), 841self . site . name , 842self . label , 843) 844}; 845write_file ( 846& self . out . join ( & rel), 847page ( & self . site . instance_name , & title, & description, Some (( & self . label , self . tip_id )), & body), 848) 849} 850 851/// Root-page layout: file listing beside a recent-commits log. 852fn overview ( & self , entries : & [ Entry ]) ->String { 853format! ( 854"<div class=\"overview\"><section class=\"files\"><h2>files</h2>\n{}</section><aside class=\"commits\">{}</aside></div>\n" , 855self . listing ( & [], entries), 856self . log_section (), 857) 858} 859 860/// Recent commits from this ref's tip, jj-style: change id (from the 861/// `change-id` commit header jj can write), short sha, summary. 862fn log_section ( & self ) ->String { 863const SHOWN : usize =10 ; 864let mut s =String :: from ( "<h2 class=\"log-heading\">recent commits</h2>\n<ol class=\"log\">\n" ); 865let mut shown =Vec :: with_capacity ( SHOWN ); 866let mut parents =Vec :: new (); 867let mut truncated =false ; 868let Ok ( walk) =self . site . repo . rev_walk ([ self . tip_id ]). all () else { 869return String :: new (); 870}; 871for ( n, info) in walk. flatten (). enumerate () { 872if n ==SHOWN { 873 truncated =true ; 874break ; 875} 876let Ok ( commit) =self 877. site 878. repo 879. find_object ( info. id ) 880. map_err ( anyhow:: Error :: from) 881. and_then ( |o|Ok ( o. try_into_commit () ?)) 882else { 883continue ; 884}; 885 shown. push ( info. id ); 886 parents. extend ( commit. parent_ids (). map ( |id| id. detach ())); 887let change_id = commit 888. decode () 889. ok () 890. and_then ( |c| c. extra_headers (). find ( "change-id" ). map ( |v| v. to_string ())); 891let cid = change_id 892. as_deref () 893. map ( |c|{ 894format! ( 895"<span class=\"cid\" data-commit=\"{}\">{}</span> " , 896 info. id , 897escape ( & c[ ..c. len (). min ( 8 )]), 898) 899}) 900. unwrap_or_default (); 901let summary = commit. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (); 902let author = commit. author (). map ( |a| a. name . to_string ()). unwrap_or_default (); 903let _ =writeln! ( 904 s, 905"<li data-oid=\"{full}\">{cid}<span class=\"sha\" data-commit=\"{full}\">{sha}</span> <span class=\"who\"><span>{author}</span>{time}</span><span class=\"msg\">{summary}</span></li>" , 906 full = info. id , 907 sha = commit. id (). shorten_or_id (), 908 author =escape ( & author), 909 time =time_of ( & commit), 910 summary =escape ( & summary), 911); 912} 913 s. push_str ( "</ol>\n" ); 914if truncated{ 915let mut frontier =Vec :: new (); 916for parentin parents{ 917if !shown. contains ( & parent) && !frontier. contains ( & parent) { 918 frontier. push ( parent); 919} 920} 921let frontier = frontier 922. iter () 923. map ( ToString :: to_string) 924. collect ::< Vec < _ >>() 925. join ( " " ); 926let _ =writeln! ( 927 s, 928"<p class=\"meta log-pagination\" data-log-frontier=\"{frontier}\">⋯ older commits not shown</p>" , 929); 930} 931 s 932} 933 934fn listing ( & self , path : & [ String ], entries : & [ Entry ]) ->String { 935let mut sub = path. join ( "/" ); 936if !sub. is_empty () { 937 sub. push ( '/' ); 938} 939let mut body ="<table class=\"list\">\n" . to_string (); 940for ( name, kind, oid) in entries{ 941match kind{ 942EntryKind :: Dir =>{ 943let name =collapse_lone_dirs ( self . site . repo , name, * oid); 944let _ =writeln! ( 945 body, 946"<tr><td><a href=\"{base}tree/{href}/\">{name}/</a></td><td class=\"size\"></td></tr>" , 947 base =self . urlbase , 948 href =encode_path ( & format! ( "{sub}{name}" )), 949 name =escape ( & name), 950); 951} 952EntryKind :: File |EntryKind :: Link =>{ 953let size =self . site . repo . find_header ( * oid). map ( |h| h. size ()). unwrap_or ( 0 ); 954let _ =writeln! ( 955 body, 956"<tr><td><a href=\"{base}blob/{href}\">{name}{sigil}</a></td><td class=\"size\">{size}</td></tr>" , 957 base =self . urlbase , 958 href =encode_path ( & format! ( "{sub}{name}" )), 959 name =escape ( name), 960 sigil =if matches! ( kind, EntryKind :: Link ) { "@" } else { "" }, 961 size =human_size ( size), 962); 963} 964EntryKind :: Submodule =>{ 965let _ =writeln! ( 966 body, 967"<tr><td>{name} @ {oid:.8}</td><td class=\"size\"></td></tr>" , 968 name =escape ( name), 969); 970} 971} 972} 973 body. push_str ( "</table>\n" ); 974 body 975} 976 977fn readme_section ( & mut self , path : & [ String ], entries : & [ Entry ]) ->String { 978match find_readme ( self . site . repo , entries) { 979Some (( name, data)) =>{ 980let text =String :: from_utf8_lossy ( & data); 981let readme =if is_markdown ( & name) { 982let urls =RelativeUrls { 983repo : self . site . repo , 984base_url : & self . site . base_url , 985urlbase : & self . urlbase , 986commit : self . tip_id , 987directory : path, 988}; 989markdown ( & mut self . hl , & text, |url, image| urls. resolve ( url, image)) 990} else { 991format! ( "<pre>{}</pre>\n" , escape ( & text)) 992}; 993format! ( "<section class=\"readme\">\n{readme}</section>\n" ) 994} 995None =>String :: new (), 996} 997} 998 999fn tree_topbar ( & self , path : & [ String ], entries : & [ Entry ]) ->String { 1000let folders = entries. iter (). filter ( |( _, kind, _) |matches! ( kind, EntryKind :: Dir )). count (); 1001let files = entries. len () - folders; 1002let mut stats =String :: from ( "<span class=\"view-stats\">" ); 1003if folders >0 { 1004let _ =write! ( stats, "<span>{folders} folder{}</span>" , if folders ==1 { "" } else { "s" }); 1005} 1006if files >0 { 1007let _ =write! ( stats, "<span>{files} file{}</span>" , if files ==1 { "" } else { "s" }); 1008} 1009 stats. push_str ( "</span>" ); 1010// the empty actions slot is where the client puts the history link 1011format! ( 1012"<header class=\"topbar\">{}{}{stats}<span class=\"actions\"></span></header>\n" , 1013self . switcher (), 1014self . crumbs ( path, false ), 1015) 1016} 1017 1018/// A no-JS `<details>` dropdown listing all refs. Baked at build time, so 1019/// pages of refs untouched since a ref was added/deleted list it stale; 1020/// the always-rebuilt index and refs pages stay fresh. 1021fn switcher ( & self ) ->String { 1022let mut s =format! ( 1023"<details class=\"switcher\"><summary>{}</summary><div class=\"menu\">" , 1024escape ( & self . label ), 1025); 1026for ( kind, heading) in [( RefKind :: Branch , "branches" ), ( RefKind :: Tag , "tags" )] { 1027let mut group =self 1028. tips 1029. iter () 1030. filter ( |t| t. kind == kind && t. static_mode . is_some ()) 1031. peekable (); 1032if group. peek (). is_none () { 1033continue ; 1034} 1035let _ =write! ( s, "<strong>{heading}</strong>" ); 1036for tin group{ 1037let current = t. name ==self . label ; 1038let _ =write! ( 1039 s, 1040"<a{class} href=\"{href}\">{name}</a>" , 1041 class =if current{ " class=\"current\"" } else { "" }, 1042 href =self . site . ref_href ( t), 1043 name =escape ( & t. name ), 1044); 1045} 1046} 1047let _ =write! ( 1048 s, 1049"<a class=\"all\" href=\"{}refs\">all refs →</a></div></details>" , 1050self . site . base_url , 1051); 1052 s 1053} 1054 1055/// Breadcrumb nav: site name / path components, all but the last linked. 1056fn crumbs ( & self , path : & [ String ], last_is_file : bool ) ->String { 1057let mut nav =format! ( 1058"<nav class=\"crumbs\"><a href=\"{href}\">{site}</a>" , 1059 href =self . site . base_url , 1060 site =escape ( self . site . crumb_name ()), 1061); 1062for ( i, comp) in path. iter (). enumerate () { 1063let is_last = i +1 == path. len (); 1064if is_last && last_is_file{ 1065let _ =write! ( nav, " / <span class=\"cur\">{}</span>" , escape ( comp)); 1066} else if is_last{ 1067let _ =write! ( nav, " / <span class=\"cur\">{}</span> /" , escape ( comp)); 1068} else { 1069let _ =write! ( 1070 nav, 1071" / <a href=\"{base}tree/{href}/\">{name}</a>" , 1072 base =self . urlbase , 1073 href =encode_path ( & path[ ..=i]. join ( "/" )), 1074 name =escape ( comp), 1075); 1076} 1077} 1078 nav. push_str ( "</nav>\n" ); 1079 nav 1080} 1081} 1082 1083/// Author time as a `<time>`: absolute in `datetime` and, on hover, in 1084/// `title`; date-only as text, which the client relativizes while recent. 1085/// The in-browser client also renders author time, so dates match site-wide. 1086fn time_of ( commit : & gix:: Commit < ' _ >) ->String { 1087author_time ( commit) 1088. map ( |t|{ 1089format! ( 1090"<time datetime=\"{}\" title=\"{}\">{}</time>" , 1091 t. format_or_unix ( gix:: date:: time:: format:: ISO8601_STRICT ), 1092 t. format_or_unix ( gix:: date:: time:: format:: ISO8601 ), 1093 t. format_or_unix ( gix:: date:: time:: format:: SHORT ), 1094) 1095}) 1096. unwrap_or_default () 1097} 1098 1099pub ( crate ) fn author_time ( commit : & gix:: Commit < ' _ >) ->Option < gix:: date:: Time > { 1100 commit. author (). ok () ?. time (). ok () 1101} 1102 1103fn find_readme ( repo : & gix:: Repository , entries : & [ Entry ]) ->Option <( String , Vec < u8 >)> { 1104[ "readme.md" , "readme" , "readme.txt" ]. iter (). find_map ( |want|{ 1105 entries. iter (). find_map ( |( name, kind, oid) |{ 1106( matches! ( kind, EntryKind :: File ) && name. to_lowercase () ==* want) 1107. then ( ||Some (( name. clone (), repo. find_object ( * oid). ok () ?. data . to_vec ()))) 1108. flatten () 1109}) 1110}) 1111} 1112 1113fn is_markdown ( name : & str ) ->bool { 1114let name = name. to_lowercase (); 1115 name. ends_with ( ".md" ) || name. ends_with ( ".markdown" ) 1116} 1117 1118pub ( crate ) fn image_mime ( path : & str ) ->Option < &' static str > { 1119match Path :: new ( path) 1120. extension () ? 1121. to_str () ? 1122. to_ascii_lowercase () 1123. as_str () 1124{ 1125"png" =>Some ( "image/png" ), 1126"jpg" |"jpeg" =>Some ( "image/jpeg" ), 1127"gif" =>Some ( "image/gif" ), 1128"webp" =>Some ( "image/webp" ), 1129"avif" =>Some ( "image/avif" ), 1130"bmp" =>Some ( "image/bmp" ), 1131"ico" =>Some ( "image/x-icon" ), 1132 _ =>None , 1133} 1134} 1135 1136/// Corrects a markdown file's relative URLs :D 1137struct RelativeUrls < ' a > { 1138repo : &' a gix:: Repository , 1139base_url : &' a str , 1140urlbase : &' a str , 1141commit : gix:: ObjectId , 1142directory : &' a [ String ], 1143} 1144 1145impl RelativeUrls < ' _ > { 1146fn resolve ( & self , url : & str , image : bool ) ->Option < String > { 1147if url. starts_with ([ '/' , '#' ]) || url. contains ( ':' ) { 1148return None ; 1149} 1150let end = url. find ([ '?' , '#' ]). unwrap_or ( url. len ()); 1151let suffix =& url[ end..]; 1152let decoded = percent_encoding:: percent_decode_str ( & url[ ..end]) 1153. decode_utf8 () 1154. ok () ?; 1155let mut path =self . directory . to_vec (); 1156for partin decoded. split ( '/' ) { 1157match part{ 1158"" |"." =>{} 1159".." =>{ 1160 path. pop () ?; 1161} 1162 part => path. push ( part. to_string ()), 1163} 1164} 1165if path. is_empty () { 1166return ( !image). then ( ||format! ( "{}{suffix}" , self . urlbase )); 1167} 1168let joined = path. join ( "/" ); 1169let tree =self . repo . find_commit ( self . commit ). ok () ?. tree (). ok () ?; 1170let entry = tree. lookup_entry_by_path ( & joined). ok () ??; 1171let mode = entry. mode (); 1172let href =encode_path ( & joined); 1173if image{ 1174 mode. is_blob () 1175. then ( ||format! ( "{}raw/{}/{href}{suffix}" , self . base_url , entry. id ())) 1176} else if mode. is_tree () { 1177Some ( format! ( "{}tree/{href}/{suffix}" , self . urlbase )) 1178} else if mode. is_blob_or_symlink () { 1179Some ( format! ( "{}blob/{href}{suffix}" , self . urlbase )) 1180} else { 1181None 1182} 1183} 1184} 1185 1186fn markdown ( 1187hl : & mut Highlighter , 1188src : & str , 1189mut resolve : impl FnMut ( & str , bool ) ->Option < String >, 1190) ->String { 1191use pulldown_cmark::{ html, CodeBlockKind , Event , Options , Parser , Tag , TagEnd }; 1192let opts =Options :: ENABLE_TABLES 1193 |Options :: ENABLE_STRIKETHROUGH 1194 |Options :: ENABLE_FOOTNOTES 1195 |Options :: ENABLE_TASKLISTS ; 1196// Buffer fenced code blocks so they get the same arborium treatment as 1197// blob pages; escape raw HTML rather than pulling in a sanitizer. 1198let mut fence: Option <( Option < &' static str >, String )> =None ; 1199let events =Parser :: new_ext ( src, opts). filter_map ( |ev|match ev{ 1200Event :: Start ( Tag :: CodeBlock ( CodeBlockKind :: Fenced ( info))) =>{ 1201// only the first word names the language: ```rust,ignore 1202let lang = info 1203. split ( |c : char | c ==',' || c. is_whitespace ()) 1204. next () 1205. and_then ( crate :: grammar_manifest:: canonical); 1206 fence =Some (( lang, String :: new ())); 1207None 1208} 1209Event :: Text ( t) if fence. is_some () =>{ 1210 fence. as_mut (). expect ( "checked in guard" ). 1 . push_str ( & t); 1211None 1212} 1213Event :: End ( TagEnd :: CodeBlock ) if fence. is_some () =>{ 1214let ( lang, code) = fence. take (). expect ( "checked in guard" ); 1215let class = lang. map ( |l|format! ( " class=\"language-{l}\"" )). unwrap_or_default (); 1216let code = lang 1217. and_then ( |lang| hl. highlight ( lang, & code). ok ()) 1218. unwrap_or_else ( ||escape ( & code). to_string ()); 1219Some ( Event :: Html ( format! ( "<pre{class}>{code}</pre>\n" ). into ())) 1220} 1221Event :: Html ( s) =>Some ( Event :: Text ( s)), 1222Event :: Start ( Tag :: Link { 1223 link_type, 1224 dest_url, 1225 title, 1226 id, 1227}) =>{ 1228let dest_url =resolve ( & dest_url, false ). map ( Into :: into). unwrap_or ( dest_url); 1229Some ( Event :: Start ( Tag :: Link { 1230 link_type, 1231 dest_url, 1232 title, 1233 id, 1234})) 1235} 1236Event :: Start ( Tag :: Image { 1237 link_type, 1238 dest_url, 1239 title, 1240 id, 1241}) =>{ 1242let dest_url =resolve ( & dest_url, true ). map ( Into :: into). unwrap_or ( dest_url); 1243Some ( Event :: Start ( Tag :: Image { 1244 link_type, 1245 dest_url, 1246 title, 1247 id, 1248})) 1249} 1250Event :: InlineHtml ( s) =>Some ( Event :: Text ( s)), 1251 ev =>Some ( ev), 1252}); 1253let mut out =String :: new (); 1254 html:: push_html ( & mut out, events); 1255 out 1256} 1257 1258fn human_size ( bytes : u64 ) ->String { 1259const UNITS : & [ & str ] =& [ "KiB" , "MiB" , "GiB" , "TiB" ]; 1260if bytes <1024 { 1261return format! ( "{bytes} B" ); 1262} 1263let mut size = bytesas f64 ; 1264let mut unit ="" ; 1265for uin UNITS { 1266 size /=1024.0 ; 1267 unit = u; 1268if size <1024.0 { 1269break ; 1270} 1271} 1272format! ( "{size:.1} {unit}" ) 1273} 1274 1275# [ cfg ( test )] 1276mod tests{ 1277use std:: collections:: BTreeMap ; 1278use std:: fs; 1279use std:: os:: unix:: fs:: symlink; 1280 1281use anyhow:: Result ; 1282 1283use crate :: highlight:: Highlighter ; 1284use crate :: testutil::{ TempDir , commit, git, grammar_cache, init_repo}; 1285 1286use super ::{ 1287BlobVersion , EntryKind , RefKind , collapse_lone_dirs, collect_entries, encode_path, 1288 find_readme, head_tip, human_size, list_refs, markdown, reuse_blob, time_of, 1289}; 1290 1291# [ test ] 1292fn encodes_url_paths () { 1293assert_eq! ( encode_path ( "a b/c?d#\u{e9}" ). to_string (), "a%20b/c%3Fd%23%C3%A9" ); 1294} 1295 1296# [ test ] 1297fn human_sizes_switch_units_at_1024 () { 1298assert_eq! ( human_size ( 0 ), "0 B" ); 1299assert_eq! ( human_size ( 1023 ), "1023 B" ); 1300assert_eq! ( human_size ( 1024 ), "1.0 KiB" ); 1301assert_eq! ( human_size ( 1536 ), "1.5 KiB" ); 1302assert_eq! ( human_size ( 1 <<20 ), "1.0 MiB" ); 1303assert_eq! ( human_size ( 1 <<40 ), "1.0 TiB" ); 1304assert_eq! ( human_size ( 1 <<50 ), "1024.0 TiB" ); 1305} 1306 1307# [ test ] 1308fn commit_time_carries_absolute_timestamp () ->Result <()> { 1309let root =TempDir :: new ( "time-of" ); 1310let repo_path = root. join ( "repo" ); 1311init_repo ( & repo_path) ?; 1312 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1313git ( & repo_path, & [ "add" , "-A" ]) ?; 1314git ( & repo_path, & [ "commit" , "-qm" , "first" , "--date=@978307200 +0000" ]) ?; 1315let repo = gix:: open ( & repo_path) ?; 1316let commit = repo. head_commit () ?; 1317assert_eq! ( 1318time_of ( & commit), 1319"<time datetime=\"2001-01-01T00:00:00+00:00\" title=\"2001-01-01 00:00:00 +0000\">2001-01-01</time>" , 1320); 1321Ok (()) 1322} 1323 1324# [ test ] 1325fn tree_entries_list_directories_first_then_by_name () ->Result <()> { 1326let root =TempDir :: new ( "entries" ); 1327let repo_path = root. join ( "repo" ); 1328init_repo ( & repo_path) ?; 1329for dirin [ "zeta" , "alpha" ] { 1330 fs:: create_dir ( repo_path. join ( dir)) ?; 1331 fs:: write ( repo_path. join ( dir). join ( "keep" ), "" ) ?; 1332} 1333 fs:: write ( repo_path. join ( "beta" ), "" ) ?; 1334 fs:: write ( repo_path. join ( "aardvark" ), "" ) ?; 1335symlink ( "beta" , repo_path. join ( "gamma" )) ?; 1336commit ( & repo_path, "first" ) ?; 1337 1338let repo = gix:: open ( & repo_path) ?; 1339let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1340let names: Vec < _ > = entries. iter (). map ( |( name, ..) | name. as_str ()). collect (); 1341assert_eq! ( names, [ "alpha" , "zeta" , "aardvark" , "beta" , "gamma" ]); 1342assert! ( matches! ( entries[ 1 ]. 1 , EntryKind :: Dir )); 1343assert! ( matches! ( entries[ 2 ]. 1 , EntryKind :: File )); 1344assert! ( matches! ( entries[ 4 ]. 1 , EntryKind :: Link )); 1345Ok (()) 1346} 1347 1348# [ test ] 1349fn lone_directory_chains_collapse_into_one_name () ->Result <()> { 1350let root =TempDir :: new ( "collapse" ); 1351let repo_path = root. join ( "repo" ); 1352init_repo ( & repo_path) ?; 1353 fs:: create_dir_all ( repo_path. join ( "a/b/c" )) ?; 1354 fs:: write ( repo_path. join ( "a/b/c/keep" ), "" ) ?; 1355 fs:: create_dir_all ( repo_path. join ( "x/y" )) ?; 1356 fs:: write ( repo_path. join ( "x/y/keep" ), "" ) ?; 1357 fs:: write ( repo_path. join ( "x/stop" ), "" ) ?; 1358commit ( & repo_path, "first" ) ?; 1359 1360let repo = gix:: open ( & repo_path) ?; 1361let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1362let collapsed: Vec < _ > = entries 1363. iter () 1364. map ( |( name, _, oid) |collapse_lone_dirs ( & repo, name, * oid)) 1365. collect (); 1366assert_eq! ( collapsed, [ "a/b/c" , "x" ]); 1367Ok (()) 1368} 1369 1370# [ test ] 1371fn readme_lookup_prefers_markdown_ignores_case_and_skips_directories () ->Result <()> { 1372let root =TempDir :: new ( "readme" ); 1373let repo_path = root. join ( "repo" ); 1374init_repo ( & repo_path) ?; 1375 fs:: write ( repo_path. join ( "readme.txt" ), "txt" ) ?; 1376 fs:: write ( repo_path. join ( "README" ), "bare" ) ?; 1377 fs:: write ( repo_path. join ( "ReadMe.MD" ), "md" ) ?; 1378commit ( & repo_path, "first" ) ?; 1379let repo = gix:: open ( & repo_path) ?; 1380let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1381let ( name, data) =find_readme ( & repo, & entries). unwrap (); 1382assert_eq! (( name. as_str (), data. as_slice ()), ( "ReadMe.MD" , b"md" . as_slice ())); 1383 1384 fs:: remove_file ( repo_path. join ( "ReadMe.MD" )) ?; 1385 fs:: create_dir ( repo_path. join ( "readme.md" )) ?; 1386 fs:: write ( repo_path. join ( "readme.md/keep" ), "" ) ?; 1387commit ( & repo_path, "second" ) ?; 1388let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1389let ( name, data) =find_readme ( & repo, & entries). unwrap (); 1390assert_eq! (( name. as_str (), data. as_slice ()), ( "README" , b"bare" . as_slice ())); 1391Ok (()) 1392} 1393 1394# [ test ] 1395fn refs_skip_non_commit_tags_and_let_branches_shadow_tags () ->Result <()> { 1396let root =TempDir :: new ( "refs" ); 1397let repo_path = root. join ( "repo" ); 1398init_repo ( & repo_path) ?; 1399 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1400commit ( & repo_path, "first" ) ?; 1401git ( & repo_path, & [ "tag" , "main" ]) ?; 1402git ( & repo_path, & [ "tag" , "v1" ]) ?; 1403let blob =git ( & repo_path, & [ "rev-parse" , "HEAD:file" ]) ?; 1404git ( & repo_path, & [ "tag" , "blobtag" , & blob]) ?; 1405 1406let repo = gix:: open ( & repo_path) ?; 1407let mut tips: Vec < _ > =list_refs ( & repo) ? 1408. into_iter () 1409. map ( |tip|( tip. kind , tip. name )) 1410. collect (); 1411 tips. sort_by ( |a, b| a. 1 . cmp ( & b. 1 )); 1412assert_eq! ( tips, [( RefKind :: Branch , "main" . into ()), ( RefKind :: Tag , "v1" . into ())]); 1413Ok (()) 1414} 1415 1416# [ test ] 1417fn head_tip_follows_head_and_falls_back_to_any_branch () ->Result <()> { 1418let root =TempDir :: new ( "head" ); 1419let repo_path = root. join ( "repo" ); 1420init_repo ( & repo_path) ?; 1421 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1422commit ( & repo_path, "first" ) ?; 1423git ( & repo_path, & [ "branch" , "aaa" ]) ?; 1424let repo = gix:: open ( & repo_path) ?; 1425let tips =list_refs ( & repo) ?; 1426assert_eq! ( head_tip ( & repo, & tips). unwrap (). name , "main" ); 1427 1428git ( & repo_path, & [ "checkout" , "-q" , "--detach" ]) ?; 1429let repo = gix:: open ( & repo_path) ?; 1430assert_eq! ( head_tip ( & repo, & tips). unwrap (). kind , RefKind :: Branch ); 1431 1432git ( & repo_path, & [ "symbolic-ref" , "HEAD" , "refs/heads/unborn" ]) ?; 1433let repo = gix:: open ( & repo_path) ?; 1434assert_eq! ( head_tip ( & repo, & tips). unwrap (). kind , RefKind :: Branch ); 1435Ok (()) 1436} 1437 1438# [ test ] 1439fn markdown_never_passes_through_raw_html () { 1440let mut hl =Highlighter :: new ( grammar_cache ()); 1441let html =markdown ( 1442& mut hl, 1443"<script>alert(1)</script>\n\n\ 1444text <b onclick=\"x\">bold</b>\n\n\ 1445```no-such-lang\n<img src=x onerror=alert(1)>\n```\n" , 1446 |_, _|None , 1447); 1448assert! ( html. contains ( "<script>" ), "{html}" ); 1449for forbiddenin [ "<script" , "<b " , "<img" ] { 1450assert! ( !html. contains ( forbidden), "{forbidden} leaked into {html}" ); 1451} 1452} 1453 1454# [ test ] 1455fn reuses_only_matching_blob_pages () ->Result <()> { 1456let root =TempDir :: new ( "blob-reuse" ); 1457let previous = root. join ( "previous" ); 1458let out = root. join ( "out" ); 1459 fs:: create_dir_all ( previous. join ( "blob/src" )) ?; 1460 fs:: write ( previous. join ( "blob/src/main.rs" ), "highlighted" ) ?; 1461let old =BlobVersion { 1462oid : "abc123" . into (), 1463mode : 0o100644 , 1464touched : "first" . into (), 1465}; 1466let previous_versions =BTreeMap :: from ([( "src/main.rs" . into (), old. clone ())]); 1467let changed =BlobVersion { 1468touched : "second" . into (), 1469 ..old. clone () 1470}; 1471let changed_versions =BTreeMap :: from ([( "src/main.rs" . into (), changed)]); 1472let versions =BTreeMap :: from ([( "src/main.rs" . into (), old)]); 1473 1474assert! ( !reuse_blob ( 1475& previous, 1476& previous_versions, 1477& changed_versions, 1478"src/main.rs" , 1479& out, 1480)); 1481assert! ( reuse_blob ( 1482& previous, 1483& previous_versions, 1484& versions, 1485"src/main.rs" , 1486& out, 1487)); 1488assert_eq! ( 1489 fs:: read_to_string ( out. join ( "blob/src/main.rs" )) ?, 1490"highlighted" , 1491); 1492Ok (()) 1493} 1494}