forks/sorcery
git clone https://git.gae.moe/forks/sorcery
37af77a
typst_support
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 document blob changing. 388if !is_markdown ( & joined) 389 && !is_typst ( & joined) 390 &&let Some ( reuse) = ctx. reuse 391 &&let Some (( previous, previous_versions)) = reuse. previous 392 &&reuse_blob ( 393 previous, 394 previous_versions, 395 reuse. versions , 396& joined, 397& ctx. out , 398) 399{ 400return Ok (()); 401} 402let data =& repo. find_object ( job. oid ) ?. data . to_vec (); 403 404let size = data. len () as u64 ; 405let image = !job. is_link &&image_mime ( & joined). is_some (); 406let raw_url =format! ( "{}raw/{}/{}" , ctx. base_url , job. oid , encode_path ( & joined)); 407let looks_binary = data[ ..data. len (). min ( 8192 )]. contains ( & 0 ); 408let text =( !job. is_link && !image && !looks_binary && size <=MAX_RENDER_BYTES ) 409. then ( ||String :: from_utf8_lossy ( data)); 410let lines = text. as_ref (). map_or ( 0 , |text| text. lines (). count ()); 411let stats =if job. is_link { 412format! ( "<span>symlink \u{2192} {}</span>" , escape ( & String :: from_utf8_lossy ( data))) 413} else if text. is_some () { 414format! ( 415"<span>{}</span><span>{lines} line{}</span>" , 416human_size ( size), 417if lines ==1 { "" } else { "s" }, 418) 419} else { 420format! ( 421"<span>{}</span><span>{}</span>" , 422if image{ 423"image" 424} else if looks_binary{ 425"binary file" 426} else { 427"large file" 428}, 429human_size ( size), 430) 431}; 432let touched = ctx 433. reuse 434. and_then ( |reuse| reuse. versions . get ( & joined)) 435. and_then ( |version| gix:: ObjectId :: from_hex ( version. touched . as_bytes ()). ok ()) 436. unwrap_or ( ctx. tip_id ); 437let mut body =format! ( 438"{repo_header}{panel}<header class=\"topbar\">{chrome}<span class=\"view-stats\">{stats}</span>\ 439<span class=\"actions\"><a class=\"raw\" href=\"{raw_url}\">raw</a></span></header>\n" , 440 repo_header = ctx. repo_header , 441 panel =commit_panel ( repo, touched), 442 chrome = job. chrome , 443); 444 445if let Some ( text) = text{ 446let name = job. path . last (). expect ( "blob path is never empty" ); 447// Plain mode defers highlighting to the client, which detects the 448// language from the path carried in data-hl. 449let ( src_attrs, code) =if ctx. highlight { 450let lang =crate :: grammar_manifest:: detect ( name); 451let code = lang 452. and_then ( |lang| hl. highlight_lines ( lang, & text). ok ()) 453. unwrap_or_else ( || text. lines (). map ( |line|escape ( line). to_string ()). collect ()); 454let class = lang. map ( |l|format! ( " language-{l}" )). unwrap_or_default (); 455( format! ( "class=\"code src{class}\"" ), code) 456} else { 457( 458format! ( "class=\"code src\" data-hl=\"{}\"" , escape ( & joined)), 459 text. lines (). map ( |line|escape ( line). to_string ()). collect (), 460) 461}; 462let mut source =format! ( "<pre {src_attrs}>" ); 463let line_count = code. len (); 464for ( i, line) in code. into_iter (). enumerate () { 465let number = i +1 ; 466let _ =write! ( 467 source, 468"<span class=\"code-line\"><a class=\"ln\" id=\"L{number}\" href=\"#L{number}\">{number}</a><span class=\"code-text\">{line}</span>" 469); 470if number < line_count{ 471 source. push_str ( "<span class=\"code-break\">\n</span>" ); 472} 473 source. push_str ( "</span>" ); 474} 475 source. push_str ( "</pre>" ); 476if is_markdown ( name) { 477let urls =RelativeUrls { 478 repo, 479base_url : & ctx. base_url , 480urlbase : & ctx. urlbase , 481commit : ctx. tip_id , 482directory : & job. path [ ..job. path . len () -1 ], 483}; 484let rendered =markdown ( hl, & text, |url, image| urls. resolve ( url, image)); 485let _ =writeln! ( 486 body, 487"<div class=\"markdown-view\">\ 488<input type=\"radio\" name=\"markdown-view\" id=\"markdown-rendered\" checked>\ 489<label for=\"markdown-rendered\">rendered</label>\ 490<input type=\"radio\" name=\"markdown-view\" id=\"markdown-code\">\ 491<label for=\"markdown-code\">code</label>\ 492<section class=\"readme rendered\">{rendered}</section>\ 493<div class=\"code-panel\">{source}</div>\ 494</div>" , 495); 496} else if is_typst ( name) { 497let urls =RelativeUrls { 498 repo, 499base_url : & ctx. base_url , 500urlbase : & ctx. urlbase , 501commit : ctx. tip_id , 502directory : & job. path [ ..job. path . len () -1 ], 503}; 504let rendered =typst ( hl, & text, |url, image| urls. resolve ( url, image)); 505let _ =writeln! ( 506 body, 507"<div class=\"markdown-view\">\ 508<input type=\"radio\" name=\"markdown-view\" id=\"markdown-rendered\" checked>\ 509<label for=\"markdown-rendered\">rendered</label>\ 510<input type=\"radio\" name=\"markdown-view\" id=\"markdown-code\">\ 511<label for=\"markdown-code\">code</label>\ 512<section class=\"readme rendered\">{rendered}</section>\ 513<div class=\"code-panel\">{source}</div>\ 514</div>" , 515); 516} else { 517let _ =writeln! ( body, "{source}" ); 518} 519} else if image{ 520let _ =writeln! ( 521 body, 522"<div class=\"image-preview\"><img src=\"{}\" alt=\"{}\"></div>" , 523escape ( & raw_url), 524escape ( & joined) 525); 526} else if !job. is_link { 527let _ =writeln! ( 528 body, 529"<div class=\"file-download\"><p>This file cannot be previewed.</p><a href=\"{}\" download=\"{}\">download raw file</a></div>" , 530escape ( & raw_url), 531escape ( job. path . last (). expect ( "blob path is never empty" )) 532); 533} 534 535let title =format! ( "{} - {} @ {}" , joined, ctx. name , ctx. label ); 536let description =format! ( "{joined} in {} at {}." , ctx. name , ctx. label ); 537write_file ( 538& ctx. out . join ( & rel), 539page ( & ctx. instance_name , & title, & description, Some (( & ctx. label , ctx. tip_id )), & body), 540) 541} 542 543fn reuse_blob ( 544previous : & Path , 545previous_versions : & BTreeMap < String , BlobVersion >, 546versions : & BTreeMap < String , BlobVersion >, 547path : & str , 548out : & Path , 549) ->bool { 550let Some ( version) = versions. get ( path) else { 551return false ; 552}; 553if previous_versions. get ( path) !=Some ( version) { 554return false ; 555} 556let previous = previous. join ( "blob" ). join ( path); 557let out = out. join ( "blob" ). join ( path); 558if !previous. is_file () 559 || out. parent (). is_none_or ( |parent| fs:: create_dir_all ( parent). is_err ()) 560{ 561return false ; 562} 563if fs:: hard_link ( & previous, & out). is_ok () || fs:: copy ( & previous, & out). is_ok () { 564return true ; 565} 566let _ = fs:: remove_file ( out); 567false 568} 569 570/// The landing page: repo header plus the HEAD branch's root tree view, with 571/// entry links pointing into the branch's own pages under `ref/`. 572pub fn render_site_index ( 573site : & Site , 574tips : & [ RefTip ], 575out : & Path , 576highlights : Arc < GrammarCache >, 577cancel : Option < BuildCancel >, 578) ->Result <()> { 579let mut body =format! ( "<a class=\"back\" href=\"/\">← back</a>\n{}" , site. repo_header ()); 580 581let head =head_tip ( site. repo , tips); 582match head{ 583None => body. push_str ( "<p class=\"meta\">no branches yet</p>\n" ), 584Some ( tip) =>{ 585let commit = site. repo . find_object ( tip. commit_id ) ?. try_into_commit () ?; 586let tree = commit. tree () ?; 587let mut r =RefRenderer :: new ( site, tips, tip, & commit, out, highlights, cancel); 588let entries =collect_entries ( & tree) ?; 589 body. push_str ( & commit_panel ( site. repo , tip. commit_id )); 590 body. push_str ( & r. tree_topbar ( & [], & entries)); 591if tip. static_mode ==Some ( StaticMode :: Highlighted ) { 592 body. push_str ( & render_languages ( 593& crate :: languages:: analyze ( site. repo , & tree) ?, 594 tip. commit_id , 595)); 596} 597 body. push_str ( & r. overview ( & entries)); 598 body. push_str ( & r. readme_section ( & [], & entries)); 599} 600} 601 602let description = site 603. description 604. clone () 605. unwrap_or_else ( ||format! ( "{} repository on {}." , site. name , site. instance_name )); 606write_file ( 607& out. join ( "index.html" ), 608page ( & site. instance_name , & site. name , & description, head. map ( |tip|( tip. name . as_str (), tip. commit_id )), & body), 609) 610} 611 612/// `data-tip` and `data-language` let the client turn each label into a 613/// link to that language's files at the tip. 614fn render_languages ( stats : & [ crate :: languages:: Stat ], tip : gix:: ObjectId ) ->String { 615let total = stats. iter (). map ( |stat| stat. bytes ). sum ::< u64 >(); 616if total ==0 { 617return String :: new (); 618} 619 620let mut html =format! ( 621"<details class=\"languages\" data-tip=\"{tip}\">\n<summary><span class=\"language-label\">languages</span>" , 622); 623for statin stats{ 624let percentage = stat. bytes as f64 * 100.0 / totalas f64 ; 625let _ =write! ( 626 html, 627"<span title=\"{} {:.1}%\" style=\"background:{};width:{percentage:.6}%\"></span>" , 628escape ( stat. name ), 629 percentage, 630 stat. color , 631); 632} 633 html. push_str ( "</summary>\n<ul>\n" ); 634for statin stats{ 635let percentage = stat. bytes as f64 * 100.0 / totalas f64 ; 636let _ =writeln! ( 637 html, 638"<li data-language=\"{}\"><i style=\"background:{}\"></i><span>{} <small>{:.1}%</small></span></li>" , 639 stat. id , 640 stat. color , 641escape ( stat. name ), 642 percentage, 643); 644} 645 html. push_str ( "</ul>\n</details>\n" ); 646 html 647} 648 649/// `/refs`: branch and tag tables with tip summaries and dates. 650pub fn render_refs_page ( site : & Site , tips : & [ RefTip ], out : & Path ) ->Result <()> { 651let head =head_tip ( site. repo , tips); 652let mut body =format! ( 653"{}<header class=\"topbar\"><nav class=\"crumbs\"><a href=\"{}\">{}</a> / <span class=\"cur\">refs</span></nav></header>\n" , 654 site. repo_header (), 655 site. base_url , 656escape ( site. crumb_name ()), 657); 658 659for ( kind, heading) in [( RefKind :: Branch , "branches" ), ( RefKind :: Tag , "tags" )] { 660// HEAD branch first, then alphabetical; tags newest first 661let mut dated: Vec < _ > = tips 662. iter () 663. filter ( |t| t. kind == kind) 664. map ( |t|{ 665let commit = site 666. repo 667. find_object ( t. commit_id ) 668. map_err ( anyhow:: Error :: from) 669. and_then ( |o|Ok ( o. try_into_commit () ?)); 670let ( summary, time, secs) =match & commit{ 671Ok ( c) =>( 672 c. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (), 673time_of ( c), 674author_time ( c). map ( |t| t. seconds ). unwrap_or ( 0 ), 675), 676Err ( _) =>( String :: new (), String :: new (), 0 ), 677}; 678( t, summary, time, secs) 679}) 680. collect (); 681if dated. is_empty () { 682continue ; 683} 684match kind{ 685RefKind :: Branch => dated. sort_by ( |a, b|{ 686let is_head = |t : & RefTip | head. is_some_and ( |head| head. name == t. name ); 687is_head ( b. 0 ). cmp ( & is_head ( a. 0 )). then_with ( || a. 0 . name . cmp ( & b. 0 . name )) 688}), 689RefKind :: Tag => dated. sort_by_key ( |d| std:: cmp:: Reverse ( d. 3 )), 690} 691 692let _ =write! ( body, "<h2>{heading}</h2>\n<table class=\"list\">\n" ); 693for ( tip, summary, time, _) in dated{ 694let _ =writeln! ( 695 body, 696"<tr><td><a href=\"{href}\">{name}</a></td><td class=\"msg\">{summary}</td><td class=\"date\">{time}</td></tr>" , 697 href = site. ref_href ( tip), 698 name =escape ( & tip. name ), 699 summary =escape ( & summary), 700 time = time, 701); 702} 703 body. push_str ( "</table>\n" ); 704} 705 706let title =format! ( "refs - {}" , site. name ); 707let description =format! ( "Branches and tags for {}." , site. name ); 708write_file ( 709& out. join ( "refs" ), 710page ( & site. instance_name , & title, & description, head. map ( |tip|( tip. name . as_str (), tip. commit_id )), & body), 711) 712} 713 714struct RefRenderer < ' a > { 715site : &' a Site < ' a >, 716tips : &' a [ RefTip ], 717out : &' a Path , 718label : String , 719/// Absolute URL prefix of this ref's pages, e.g. `/user/repo/ref/main/`. 720urlbase : String , 721tip_id : gix:: ObjectId , 722hl : Highlighter , 723cancel : Option < BuildCancel >, 724} 725 726enum EntryKind { 727Dir , 728File , 729Link , 730Submodule , 731} 732 733type Entry =( String , EntryKind , gix:: ObjectId ); 734 735fn collect_entries ( tree : & gix:: Tree < ' _ >) ->Result < Vec < Entry >> { 736let mut entries =Vec :: new (); 737for entryin tree. iter () { 738let entry = entry?; 739let mode = entry. mode (); 740let kind =if mode. is_tree () { 741EntryKind :: Dir 742} else if mode. is_link () { 743EntryKind :: Link 744} else if mode. is_commit () { 745EntryKind :: Submodule 746} else { 747EntryKind :: File 748}; 749 entries. push (( entry. filename (). to_string (), kind, entry. oid (). to_owned ())); 750} 751 entries. sort_by ( |a, b|{ 752let rank = |k : & EntryKind | !matches! ( k, EntryKind :: Dir ); 753rank ( & a. 1 ). cmp ( & rank ( & b. 1 )). then_with ( || a. 0 . cmp ( & b. 0 )) 754}); 755Ok ( entries) 756} 757 758/// `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone 759/// directories reads better as one link than as three clicks. 760fn collapse_lone_dirs ( repo : & gix:: Repository , name : & str , mut oid : gix:: ObjectId ) ->String { 761let mut path = name. to_string (); 762while let Ok ( tree) = repo. find_tree ( oid) { 763let mut entries = tree. iter (); 764match ( entries. next (), entries. next ()) { 765( Some ( Ok ( only)), None ) if only. mode (). is_tree () =>{ 766let _ =write! ( path, "/{}" , only. filename ()); 767 oid = only. oid (). to_owned (); 768} 769 _ =>break , 770} 771} 772 path 773} 774 775impl < ' a > RefRenderer < ' a > { 776fn new ( 777site : &' a Site < ' a >, 778tips : &' a [ RefTip ], 779tip : & RefTip , 780commit : & gix:: Commit , 781out : &' a Path , 782highlights : Arc < GrammarCache >, 783cancel : Option < BuildCancel >, 784) ->Self { 785RefRenderer { 786 site, 787 tips, 788 out, 789label : tip. name . clone (), 790urlbase : format! ( "{}ref/{}/" , site. base_url , encode_path ( & tip. name )), 791tip_id : commit. id , 792hl : Highlighter :: new ( highlights), 793 cancel, 794} 795} 796 797fn walk ( 798& mut self , 799tree : & gix:: Tree < ' _ >, 800path : & mut Vec < String >, 801blobs : & mut Vec < BlobJob >, 802) ->Result <()> { 803bail_if_cancelled ( & self . cancel ) ?; 804let entries =collect_entries ( tree) ?; 805self . tree_page ( path, & entries) ?; 806 807for ( name, kind, oid) in entries{ 808 path. push ( name); 809match kind{ 810EntryKind :: Dir =>{ 811let subtree =self . site . repo . find_object ( oid) ?. try_into_tree () ?; 812self . walk ( & subtree, path, blobs) ?; 813} 814EntryKind :: File |EntryKind :: Link =>{ 815 blobs. push ( BlobJob { 816chrome : format! ( "{}{}" , self . switcher (), self . crumbs ( path, true )), 817path : path. clone (), 818 oid, 819is_link : matches! ( kind, EntryKind :: Link ), 820}); 821} 822EntryKind :: Submodule =>{} 823} 824 path. pop (); 825} 826Ok (()) 827} 828 829fn tree_page ( & mut self , path : & [ String ], entries : & [ Entry ]) ->Result <()> { 830let rel =if path. is_empty () { 831"index.html" . to_string () 832} else { 833format! ( "tree/{}/index.html" , path. join ( "/" )) 834}; 835let mut body =if path. is_empty () { 836"<a class=\"back\" href=\"/\">← back</a>\n" . to_string () 837} else { 838String :: new () 839}; 840 body. push_str ( & self . site . repo_header ()); 841 body. push_str ( & commit_panel ( self . site . repo , self . tip_id )); 842 body. push_str ( & self . tree_topbar ( path, entries)); 843if path. is_empty () { 844 body. push_str ( & self . overview ( entries)); 845} else { 846 body. push_str ( "<h2 class=\"file-heading\">files</h2>\n" ); 847 body. push_str ( & self . listing ( path, entries)); 848} 849 body. push_str ( & self . readme_section ( path, entries)); 850 851let title =if path. is_empty () { 852format! ( "{} @ {}" , self . site . name , self . label ) 853} else { 854format! ( "{}/ - {} @ {}" , path. join ( "/" ), self . site . name , self . label ) 855}; 856let description =if path. is_empty () { 857format! ( "Source tree for {} at {}." , self . site . name , self . label ) 858} else { 859format! ( 860"{} in {} at {}." , 861 path. join ( "/" ), 862self . site . name , 863self . label , 864) 865}; 866write_file ( 867& self . out . join ( & rel), 868page ( & self . site . instance_name , & title, & description, Some (( & self . label , self . tip_id )), & body), 869) 870} 871 872/// Root-page layout: file listing beside a recent-commits log. 873fn overview ( & self , entries : & [ Entry ]) ->String { 874format! ( 875"<div class=\"overview\"><section class=\"files\"><h2>files</h2>\n{}</section><aside class=\"commits\">{}</aside></div>\n" , 876self . listing ( & [], entries), 877self . log_section (), 878) 879} 880 881/// Recent commits from this ref's tip, jj-style: change id (from the 882/// `change-id` commit header jj can write), short sha, summary. 883fn log_section ( & self ) ->String { 884const SHOWN : usize =10 ; 885let mut s =String :: from ( "<h2 class=\"log-heading\">recent commits</h2>\n<ol class=\"log\">\n" ); 886let mut shown =Vec :: with_capacity ( SHOWN ); 887let mut parents =Vec :: new (); 888let mut truncated =false ; 889let Ok ( walk) =self . site . repo . rev_walk ([ self . tip_id ]). all () else { 890return String :: new (); 891}; 892for ( n, info) in walk. flatten (). enumerate () { 893if n ==SHOWN { 894 truncated =true ; 895break ; 896} 897let Ok ( commit) =self 898. site 899. repo 900. find_object ( info. id ) 901. map_err ( anyhow:: Error :: from) 902. and_then ( |o|Ok ( o. try_into_commit () ?)) 903else { 904continue ; 905}; 906 shown. push ( info. id ); 907 parents. extend ( commit. parent_ids (). map ( |id| id. detach ())); 908let change_id = commit 909. decode () 910. ok () 911. and_then ( |c| c. extra_headers (). find ( "change-id" ). map ( |v| v. to_string ())); 912let cid = change_id 913. as_deref () 914. map ( |c|{ 915format! ( 916"<span class=\"cid\" data-commit=\"{}\">{}</span> " , 917 info. id , 918escape ( & c[ ..c. len (). min ( 8 )]), 919) 920}) 921. unwrap_or_default (); 922let summary = commit. message (). map ( |m| m. summary (). to_string ()). unwrap_or_default (); 923let author = commit. author (). map ( |a| a. name . to_string ()). unwrap_or_default (); 924let _ =writeln! ( 925 s, 926"<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>" , 927 full = info. id , 928 sha = commit. id (). shorten_or_id (), 929 author =escape ( & author), 930 time =time_of ( & commit), 931 summary =escape ( & summary), 932); 933} 934 s. push_str ( "</ol>\n" ); 935if truncated{ 936let mut frontier =Vec :: new (); 937for parentin parents{ 938if !shown. contains ( & parent) && !frontier. contains ( & parent) { 939 frontier. push ( parent); 940} 941} 942let frontier = frontier 943. iter () 944. map ( ToString :: to_string) 945. collect ::< Vec < _ >>() 946. join ( " " ); 947let _ =writeln! ( 948 s, 949"<p class=\"meta log-pagination\" data-log-frontier=\"{frontier}\">⋯ older commits not shown</p>" , 950); 951} 952 s 953} 954 955fn listing ( & self , path : & [ String ], entries : & [ Entry ]) ->String { 956let mut sub = path. join ( "/" ); 957if !sub. is_empty () { 958 sub. push ( '/' ); 959} 960let mut body ="<table class=\"list\">\n" . to_string (); 961for ( name, kind, oid) in entries{ 962match kind{ 963EntryKind :: Dir =>{ 964let name =collapse_lone_dirs ( self . site . repo , name, * oid); 965let _ =writeln! ( 966 body, 967"<tr><td><a href=\"{base}tree/{href}/\">{name}/</a></td><td class=\"size\"></td></tr>" , 968 base =self . urlbase , 969 href =encode_path ( & format! ( "{sub}{name}" )), 970 name =escape ( & name), 971); 972} 973EntryKind :: File |EntryKind :: Link =>{ 974let size =self . site . repo . find_header ( * oid). map ( |h| h. size ()). unwrap_or ( 0 ); 975let _ =writeln! ( 976 body, 977"<tr><td><a href=\"{base}blob/{href}\">{name}{sigil}</a></td><td class=\"size\">{size}</td></tr>" , 978 base =self . urlbase , 979 href =encode_path ( & format! ( "{sub}{name}" )), 980 name =escape ( name), 981 sigil =if matches! ( kind, EntryKind :: Link ) { "@" } else { "" }, 982 size =human_size ( size), 983); 984} 985EntryKind :: Submodule =>{ 986let _ =writeln! ( 987 body, 988"<tr><td>{name} @ {oid:.8}</td><td class=\"size\"></td></tr>" , 989 name =escape ( name), 990); 991} 992} 993} 994 body. push_str ( "</table>\n" ); 995 body 996} 997 998fn readme_section ( & mut self , path : & [ String ], entries : & [ Entry ]) ->String { 999match find_readme ( self . site . repo , entries) { 1000Some (( name, data)) =>{ 1001let text =String :: from_utf8_lossy ( & data); 1002let readme =if is_markdown ( & name) { 1003let urls =RelativeUrls { 1004repo : self . site . repo , 1005base_url : & self . site . base_url , 1006urlbase : & self . urlbase , 1007commit : self . tip_id , 1008directory : path, 1009}; 1010markdown ( & mut self . hl , & text, |url, image| urls. resolve ( url, image)) 1011} else if is_typst ( & name) { 1012let urls =RelativeUrls { 1013repo : self . site . repo , 1014base_url : & self . site . base_url , 1015urlbase : & self . urlbase , 1016commit : self . tip_id , 1017directory : path, 1018}; 1019typst ( & mut self . hl , & text, |url, image| urls. resolve ( url, image)) 1020} else { 1021format! ( "<pre>{}</pre>\n" , escape ( & text)) 1022}; 1023format! ( "<section class=\"readme\">\n{readme}</section>\n" ) 1024} 1025None =>String :: new (), 1026} 1027} 1028 1029fn tree_topbar ( & self , path : & [ String ], entries : & [ Entry ]) ->String { 1030let folders = entries. iter (). filter ( |( _, kind, _) |matches! ( kind, EntryKind :: Dir )). count (); 1031let files = entries. len () - folders; 1032let mut stats =String :: from ( "<span class=\"view-stats\">" ); 1033if folders >0 { 1034let _ =write! ( stats, "<span>{folders} folder{}</span>" , if folders ==1 { "" } else { "s" }); 1035} 1036if files >0 { 1037let _ =write! ( stats, "<span>{files} file{}</span>" , if files ==1 { "" } else { "s" }); 1038} 1039 stats. push_str ( "</span>" ); 1040// the empty actions slot is where the client puts the history link 1041format! ( 1042"<header class=\"topbar\">{}{}{stats}<span class=\"actions\"></span></header>\n" , 1043self . switcher (), 1044self . crumbs ( path, false ), 1045) 1046} 1047 1048/// A no-JS `<details>` dropdown listing all refs. Baked at build time, so 1049/// pages of refs untouched since a ref was added/deleted list it stale; 1050/// the always-rebuilt index and refs pages stay fresh. 1051fn switcher ( & self ) ->String { 1052let mut s =format! ( 1053"<details class=\"switcher\"><summary>{}</summary><div class=\"menu\">" , 1054escape ( & self . label ), 1055); 1056for ( kind, heading) in [( RefKind :: Branch , "branches" ), ( RefKind :: Tag , "tags" )] { 1057let mut group =self 1058. tips 1059. iter () 1060. filter ( |t| t. kind == kind && t. static_mode . is_some ()) 1061. peekable (); 1062if group. peek (). is_none () { 1063continue ; 1064} 1065let _ =write! ( s, "<strong>{heading}</strong>" ); 1066for tin group{ 1067let current = t. name ==self . label ; 1068let _ =write! ( 1069 s, 1070"<a{class} href=\"{href}\">{name}</a>" , 1071 class =if current{ " class=\"current\"" } else { "" }, 1072 href =self . site . ref_href ( t), 1073 name =escape ( & t. name ), 1074); 1075} 1076} 1077let _ =write! ( 1078 s, 1079"<a class=\"all\" href=\"{}refs\">all refs →</a></div></details>" , 1080self . site . base_url , 1081); 1082 s 1083} 1084 1085/// Breadcrumb nav: site name / path components, all but the last linked. 1086fn crumbs ( & self , path : & [ String ], last_is_file : bool ) ->String { 1087let mut nav =format! ( 1088"<nav class=\"crumbs\"><a href=\"{href}\">{site}</a>" , 1089 href =self . site . base_url , 1090 site =escape ( self . site . crumb_name ()), 1091); 1092for ( i, comp) in path. iter (). enumerate () { 1093let is_last = i +1 == path. len (); 1094if is_last && last_is_file{ 1095let _ =write! ( nav, " / <span class=\"cur\">{}</span>" , escape ( comp)); 1096} else if is_last{ 1097let _ =write! ( nav, " / <span class=\"cur\">{}</span> /" , escape ( comp)); 1098} else { 1099let _ =write! ( 1100 nav, 1101" / <a href=\"{base}tree/{href}/\">{name}</a>" , 1102 base =self . urlbase , 1103 href =encode_path ( & path[ ..=i]. join ( "/" )), 1104 name =escape ( comp), 1105); 1106} 1107} 1108 nav. push_str ( "</nav>\n" ); 1109 nav 1110} 1111} 1112 1113/// Author time as a `<time>`: absolute in `datetime` and, on hover, in 1114/// `title`; date-only as text, which the client relativizes while recent. 1115/// The in-browser client also renders author time, so dates match site-wide. 1116fn time_of ( commit : & gix:: Commit < ' _ >) ->String { 1117author_time ( commit) 1118. map ( |t|{ 1119format! ( 1120"<time datetime=\"{}\" title=\"{}\">{}</time>" , 1121 t. format_or_unix ( gix:: date:: time:: format:: ISO8601_STRICT ), 1122 t. format_or_unix ( gix:: date:: time:: format:: ISO8601 ), 1123 t. format_or_unix ( gix:: date:: time:: format:: SHORT ), 1124) 1125}) 1126. unwrap_or_default () 1127} 1128 1129pub ( crate ) fn author_time ( commit : & gix:: Commit < ' _ >) ->Option < gix:: date:: Time > { 1130 commit. author (). ok () ?. time (). ok () 1131} 1132 1133fn find_readme ( repo : & gix:: Repository , entries : & [ Entry ]) ->Option <( String , Vec < u8 >)> { 1134[ "readme.md" , "readme" , "readme.txt" , "readme.typ" ]. iter (). find_map ( |want|{ 1135if * want =="readme.typ" && !cfg! ( feature ="typst" ) { return None ; } 1136 entries. iter (). find_map ( |( name, kind, oid) |{ 1137( matches! ( kind, EntryKind :: File ) && name. to_lowercase () ==* want) 1138. then ( ||Some (( name. clone (), repo. find_object ( * oid). ok () ?. data . to_vec ()))) 1139. flatten () 1140}) 1141}) 1142} 1143 1144fn is_markdown ( name : & str ) ->bool { 1145let name = name. to_lowercase (); 1146 name. ends_with ( ".md" ) || name. ends_with ( ".markdown" ) 1147} 1148 1149fn is_typst ( name : & str ) ->bool { 1150let name = name. to_lowercase (); 1151cfg! ( feature ="typst" ) && name. ends_with ( ".typ" ) 1152} 1153 1154pub ( crate ) fn image_mime ( path : & str ) ->Option < &' static str > { 1155match Path :: new ( path) 1156. extension () ? 1157. to_str () ? 1158. to_ascii_lowercase () 1159. as_str () 1160{ 1161"png" =>Some ( "image/png" ), 1162"jpg" |"jpeg" =>Some ( "image/jpeg" ), 1163"gif" =>Some ( "image/gif" ), 1164"webp" =>Some ( "image/webp" ), 1165"avif" =>Some ( "image/avif" ), 1166"bmp" =>Some ( "image/bmp" ), 1167"ico" =>Some ( "image/x-icon" ), 1168 _ =>None , 1169} 1170} 1171 1172/// Corrects a markdown file's relative URLs :D 1173struct RelativeUrls < ' a > { 1174repo : &' a gix:: Repository , 1175base_url : &' a str , 1176urlbase : &' a str , 1177commit : gix:: ObjectId , 1178directory : &' a [ String ], 1179} 1180 1181impl RelativeUrls < ' _ > { 1182fn resolve ( & self , url : & str , image : bool ) ->Option < String > { 1183if url. starts_with ([ '/' , '#' ]) || url. contains ( ':' ) { 1184return None ; 1185} 1186let end = url. find ([ '?' , '#' ]). unwrap_or ( url. len ()); 1187let suffix =& url[ end..]; 1188let decoded = percent_encoding:: percent_decode_str ( & url[ ..end]) 1189. decode_utf8 () 1190. ok () ?; 1191let mut path =self . directory . to_vec (); 1192for partin decoded. split ( '/' ) { 1193match part{ 1194"" |"." =>{} 1195".." =>{ 1196 path. pop () ?; 1197} 1198 part => path. push ( part. to_string ()), 1199} 1200} 1201if path. is_empty () { 1202return ( !image). then ( ||format! ( "{}{suffix}" , self . urlbase )); 1203} 1204let joined = path. join ( "/" ); 1205let tree =self . repo . find_commit ( self . commit ). ok () ?. tree (). ok () ?; 1206let entry = tree. lookup_entry_by_path ( & joined). ok () ??; 1207let mode = entry. mode (); 1208let href =encode_path ( & joined); 1209if image{ 1210 mode. is_blob () 1211. then ( ||format! ( "{}raw/{}/{href}{suffix}" , self . base_url , entry. id ())) 1212} else if mode. is_tree () { 1213Some ( format! ( "{}tree/{href}/{suffix}" , self . urlbase )) 1214} else if mode. is_blob_or_symlink () { 1215Some ( format! ( "{}blob/{href}{suffix}" , self . urlbase )) 1216} else { 1217None 1218} 1219} 1220} 1221 1222fn markdown ( 1223hl : & mut Highlighter , 1224src : & str , 1225mut resolve : impl FnMut ( & str , bool ) ->Option < String >, 1226) ->String { 1227use pulldown_cmark::{ html, CodeBlockKind , Event , Options , Parser , Tag , TagEnd }; 1228let opts =Options :: ENABLE_TABLES 1229 |Options :: ENABLE_STRIKETHROUGH 1230 |Options :: ENABLE_FOOTNOTES 1231 |Options :: ENABLE_TASKLISTS ; 1232// Buffer fenced code blocks so they get the same arborium treatment as 1233// blob pages; escape raw HTML rather than pulling in a sanitizer. 1234let mut fence: Option <( Option < &' static str >, String )> =None ; 1235let events =Parser :: new_ext ( src, opts). filter_map ( |ev|match ev{ 1236Event :: Start ( Tag :: CodeBlock ( CodeBlockKind :: Fenced ( info))) =>{ 1237// only the first word names the language: ```rust,ignore 1238let lang = info 1239. split ( |c : char | c ==',' || c. is_whitespace ()) 1240. next () 1241. and_then ( crate :: grammar_manifest:: canonical); 1242 fence =Some (( lang, String :: new ())); 1243None 1244} 1245Event :: Text ( t) if fence. is_some () =>{ 1246 fence. as_mut (). expect ( "checked in guard" ). 1 . push_str ( & t); 1247None 1248} 1249Event :: End ( TagEnd :: CodeBlock ) if fence. is_some () =>{ 1250let ( lang, code) = fence. take (). expect ( "checked in guard" ); 1251let class = lang. map ( |l|format! ( " class=\"language-{l}\"" )). unwrap_or_default (); 1252let code = lang 1253. and_then ( |lang| hl. highlight ( lang, & code). ok ()) 1254. unwrap_or_else ( ||escape ( & code). to_string ()); 1255Some ( Event :: Html ( format! ( "<pre{class}>{code}</pre>\n" ). into ())) 1256} 1257Event :: Html ( s) =>Some ( Event :: Text ( s)), 1258Event :: Start ( Tag :: Link { 1259 link_type, 1260 dest_url, 1261 title, 1262 id, 1263}) =>{ 1264let dest_url =resolve ( & dest_url, false ). map ( Into :: into). unwrap_or ( dest_url); 1265Some ( Event :: Start ( Tag :: Link { 1266 link_type, 1267 dest_url, 1268 title, 1269 id, 1270})) 1271} 1272Event :: Start ( Tag :: Image { 1273 link_type, 1274 dest_url, 1275 title, 1276 id, 1277}) =>{ 1278let dest_url =resolve ( & dest_url, true ). map ( Into :: into). unwrap_or ( dest_url); 1279Some ( Event :: Start ( Tag :: Image { 1280 link_type, 1281 dest_url, 1282 title, 1283 id, 1284})) 1285} 1286Event :: InlineHtml ( s) =>Some ( Event :: Text ( s)), 1287 ev =>Some ( ev), 1288}); 1289let mut out =String :: new (); 1290 html:: push_html ( & mut out, events); 1291 out 1292} 1293 1294# [ cfg ( not ( feature = "typst" ))] 1295fn typst ( 1296_hl : & mut Highlighter , 1297src : & str , 1298_resolve : impl FnMut ( & str , bool ) ->Option < String >, 1299) ->String { 1300format! ( "<pre>{}</pre>\n" , escape ( src)) 1301} 1302 1303# [ cfg ( feature = "typst" )] 1304fn typst ( 1305hl : & mut Highlighter , 1306src : & str , 1307mut resolve : impl FnMut ( & str , bool ) ->Option < String >, 1308) ->String { 1309use typst_syntax::{ ast::{ self , AstNode , Expr }, parse}; 1310 1311fn image ( call : ast:: FuncCall < ' _ >) ->Option <( String , String )> { 1312let Expr :: Ident ( name) = call. callee () else { return None }; 1313if name. get () !="image" { return None ; } 1314let mut path =None ; 1315let mut alt =String :: new (); 1316for argin call. args (). items () { 1317match arg{ 1318 ast:: Arg :: Pos ( Expr :: Str ( value)) if path. is_none () => path =Some ( value. get (). to_string ()), 1319 ast:: Arg :: Named ( named) if named. name (). get () =="alt" =>{ 1320let Expr :: Str ( value) = named. expr () else { return None }; 1321 alt = value. get (). to_string (); 1322} 1323// Layout options do not affect the HTML preview. 1324 ast:: Arg :: Named ( named) if matches! ( named. name (). get (). as_str (), "width" |"height" |"fit" ) =>{} 1325 _ =>return None , 1326} 1327} 1328Some (( path?, alt)) 1329} 1330 1331fn markup ( value : ast:: Markup < ' _ >, hl : & mut Highlighter , resolve : & mut impl FnMut ( & str , bool ) ->Option < String >, paragraphs : bool ) ->String { 1332let mut out =String :: new (); 1333let mut paragraph =false ; 1334let mut list ="" ; 1335for exprin value. exprs () { 1336let list_kind =match expr{ 1337Expr :: ListItem ( _) =>"ul" , 1338Expr :: EnumItem ( _) =>"ol" , 1339Expr :: TermItem ( _) =>"dl" , 1340 _ =>"" , 1341}; 1342if matches! ( expr, Expr :: Space ( _) |Expr :: Parbreak ( _)) && !list. is_empty () { 1343continue ; 1344} 1345if list != list_kind && !list. is_empty () { 1346let _ =writeln! ( out, "</{list}>" ); 1347 list ="" ; 1348} 1349let block = !list_kind. is_empty () ||matches! ( expr, Expr :: Heading ( _) |Expr :: Parbreak ( _)) 1350 ||matches! ( expr, Expr :: Raw ( raw ) if raw . block ()); 1351if block && paragraph{ 1352 out. push_str ( "</p>\n" ); 1353 paragraph =false ; 1354} 1355if !list_kind. is_empty () && list. is_empty () { 1356 list = list_kind; 1357let _ =writeln! ( out, "<{list}>" ); 1358} 1359if !block && !paragraph && paragraphs{ 1360if matches! ( expr, Expr :: Space ( _)) { continue ; } 1361 out. push_str ( "<p>" ); 1362 paragraph =true ; 1363} 1364match expr{ 1365Expr :: Text ( text) => out. push_str ( & escape ( text. get ())), 1366Expr :: Space ( _) => out. push ( ' ' ), 1367Expr :: Parbreak ( _) =>{}, 1368Expr :: Linebreak ( _) => out. push_str ( "<br>\n" ), 1369Expr :: Escape ( value) => out. push_str ( & escape ( & value. get (). to_string ())), 1370Expr :: Shorthand ( value) => out. push_str ( & escape ( & value. get (). to_string ())), 1371Expr :: Strong ( value) =>{ 1372let body =markup_inline ( value. body (), hl, resolve); 1373let _ =write! ( out, "<strong>{body}</strong>" ); 1374} 1375Expr :: Emph ( value) =>{ 1376let body =markup_inline ( value. body (), hl, resolve); 1377let _ =write! ( out, "<em>{body}</em>" ); 1378} 1379Expr :: Heading ( value) =>{ 1380let level = value. depth (). get (). min ( 6 ); 1381let body =markup_inline ( value. body (), hl, resolve); 1382let _ =writeln! ( out, "<h{level}>{body}</h{level}>" ); 1383} 1384Expr :: ListItem ( value) =>{ 1385let body =markup ( value. body (), hl, resolve, true ); 1386let _ =writeln! ( out, "<li>{body}</li>" ); 1387} 1388Expr :: EnumItem ( value) =>{ 1389let number = value. number (). map ( |n|format! ( " value=\"{n}\"" )). unwrap_or_default (); 1390let body =markup ( value. body (), hl, resolve, true ); 1391let _ =writeln! ( out, "<li{number}>{body}</li>" ); 1392} 1393Expr :: TermItem ( value) =>{ 1394let term =markup_inline ( value. term (), hl, resolve); 1395let body =markup ( value. description (), hl, resolve, true ); 1396let _ =writeln! ( out, "<dt>{term}</dt><dd>{body}</dd>" ); 1397} 1398Expr :: Link ( value) =>{ 1399let target = value. get (). as_str (); 1400let href =resolve ( target, false ). unwrap_or_else ( || target. to_owned ()); 1401let _ =write! ( out, "<a href=\"{}\">{}</a>" , escape ( & href), escape ( target)); 1402} 1403Expr :: Raw ( raw) =>{ 1404let code = raw. lines (). map ( |line| line. get (). as_str ()). collect ::< Vec < _ >>(). join ( "\n" ); 1405if raw. block () { 1406let lang = raw. lang (). and_then ( |lang|crate :: grammar_manifest:: canonical ( lang. get ())); 1407let class = lang. map ( |lang|format! ( " class=\"language-{lang}\"" )). unwrap_or_default (); 1408let code = lang. and_then ( |lang| hl. highlight ( lang, & code). ok ()). unwrap_or_else ( ||escape ( & code). to_string ()); 1409let _ =writeln! ( out, "<pre{class}>{code}</pre>" ); 1410} else { 1411let _ =write! ( out, "<code>{}</code>" , escape ( & code)); 1412} 1413} 1414Expr :: FuncCall ( call) if image ( call). is_some () =>{ 1415let ( path, alt) =image ( call). unwrap (); 1416let url =resolve ( & path, true ). unwrap_or ( path); 1417let _ =write! ( out, "<img src=\"{}\" alt=\"{}\">" , escape ( & url), escape ( & alt)); 1418} 1419Expr :: SmartQuote ( _) => out. push_str ( & escape ( & expr. to_untyped (). full_text ())), 1420 _ =>{ 1421let source = expr. to_untyped (). full_text (); 1422let prefix =if matches! ( expr, Expr :: Equation ( _) |Expr :: Label ( _) |Expr :: Ref ( _)) { "" } else { "#" }; 1423let _ =write! ( out, "<code>{prefix}{}</code>" , escape ( & source)); 1424} 1425} 1426} 1427if paragraph{ out. push_str ( "</p>\n" ); } 1428if !list. is_empty () { let _ =writeln! ( out, "</{list}>" ); } 1429 out 1430} 1431 1432fn markup_inline ( value : ast:: Markup < ' _ >, hl : & mut Highlighter , resolve : & mut impl FnMut ( & str , bool ) ->Option < String >) ->String { 1433markup ( value, hl, resolve, false ) 1434} 1435 1436let tree =parse ( src); 1437if tree. errors_and_warnings (). 0 . is_empty () { 1438if let Some ( root) = tree. cast ::< ast:: Markup >() { 1439return markup ( root, hl, & mut resolve, true ); 1440} 1441} 1442format! ( "<pre>{}</pre>\n" , escape ( src)) 1443} 1444 1445fn human_size ( bytes : u64 ) ->String { 1446const UNITS : & [ & str ] =& [ "KiB" , "MiB" , "GiB" , "TiB" ]; 1447if bytes <1024 { 1448return format! ( "{bytes} B" ); 1449} 1450let mut size = bytesas f64 ; 1451let mut unit ="" ; 1452for uin UNITS { 1453 size /=1024.0 ; 1454 unit = u; 1455if size <1024.0 { 1456break ; 1457} 1458} 1459format! ( "{size:.1} {unit}" ) 1460} 1461 1462# [ cfg ( test )] 1463mod tests{ 1464use std:: collections:: BTreeMap ; 1465use std:: fs; 1466use std:: os:: unix:: fs:: symlink; 1467 1468use anyhow:: Result ; 1469 1470use crate :: highlight:: Highlighter ; 1471use crate :: testutil::{ TempDir , commit, git, grammar_cache, init_repo}; 1472 1473use super ::{ 1474BlobVersion , EntryKind , RefKind , collapse_lone_dirs, collect_entries, encode_path, 1475 find_readme, head_tip, human_size, list_refs, markdown, reuse_blob, time_of, typst, 1476}; 1477 1478# [ test ] 1479fn encodes_url_paths () { 1480assert_eq! ( encode_path ( "a b/c?d#\u{e9}" ). to_string (), "a%20b/c%3Fd%23%C3%A9" ); 1481} 1482 1483# [ test ] 1484fn human_sizes_switch_units_at_1024 () { 1485assert_eq! ( human_size ( 0 ), "0 B" ); 1486assert_eq! ( human_size ( 1023 ), "1023 B" ); 1487assert_eq! ( human_size ( 1024 ), "1.0 KiB" ); 1488assert_eq! ( human_size ( 1536 ), "1.5 KiB" ); 1489assert_eq! ( human_size ( 1 <<20 ), "1.0 MiB" ); 1490assert_eq! ( human_size ( 1 <<40 ), "1.0 TiB" ); 1491assert_eq! ( human_size ( 1 <<50 ), "1024.0 TiB" ); 1492} 1493 1494# [ test ] 1495fn commit_time_carries_absolute_timestamp () ->Result <()> { 1496let root =TempDir :: new ( "time-of" ); 1497let repo_path = root. join ( "repo" ); 1498init_repo ( & repo_path) ?; 1499 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1500git ( & repo_path, & [ "add" , "-A" ]) ?; 1501git ( & repo_path, & [ "commit" , "-qm" , "first" , "--date=@978307200 +0000" ]) ?; 1502let repo = gix:: open ( & repo_path) ?; 1503let commit = repo. head_commit () ?; 1504assert_eq! ( 1505time_of ( & commit), 1506"<time datetime=\"2001-01-01T00:00:00+00:00\" title=\"2001-01-01 00:00:00 +0000\">2001-01-01</time>" , 1507); 1508Ok (()) 1509} 1510 1511# [ test ] 1512fn tree_entries_list_directories_first_then_by_name () ->Result <()> { 1513let root =TempDir :: new ( "entries" ); 1514let repo_path = root. join ( "repo" ); 1515init_repo ( & repo_path) ?; 1516for dirin [ "zeta" , "alpha" ] { 1517 fs:: create_dir ( repo_path. join ( dir)) ?; 1518 fs:: write ( repo_path. join ( dir). join ( "keep" ), "" ) ?; 1519} 1520 fs:: write ( repo_path. join ( "beta" ), "" ) ?; 1521 fs:: write ( repo_path. join ( "aardvark" ), "" ) ?; 1522symlink ( "beta" , repo_path. join ( "gamma" )) ?; 1523commit ( & repo_path, "first" ) ?; 1524 1525let repo = gix:: open ( & repo_path) ?; 1526let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1527let names: Vec < _ > = entries. iter (). map ( |( name, ..) | name. as_str ()). collect (); 1528assert_eq! ( names, [ "alpha" , "zeta" , "aardvark" , "beta" , "gamma" ]); 1529assert! ( matches! ( entries[ 1 ]. 1 , EntryKind :: Dir )); 1530assert! ( matches! ( entries[ 2 ]. 1 , EntryKind :: File )); 1531assert! ( matches! ( entries[ 4 ]. 1 , EntryKind :: Link )); 1532Ok (()) 1533} 1534 1535# [ test ] 1536fn lone_directory_chains_collapse_into_one_name () ->Result <()> { 1537let root =TempDir :: new ( "collapse" ); 1538let repo_path = root. join ( "repo" ); 1539init_repo ( & repo_path) ?; 1540 fs:: create_dir_all ( repo_path. join ( "a/b/c" )) ?; 1541 fs:: write ( repo_path. join ( "a/b/c/keep" ), "" ) ?; 1542 fs:: create_dir_all ( repo_path. join ( "x/y" )) ?; 1543 fs:: write ( repo_path. join ( "x/y/keep" ), "" ) ?; 1544 fs:: write ( repo_path. join ( "x/stop" ), "" ) ?; 1545commit ( & repo_path, "first" ) ?; 1546 1547let repo = gix:: open ( & repo_path) ?; 1548let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1549let collapsed: Vec < _ > = entries 1550. iter () 1551. map ( |( name, _, oid) |collapse_lone_dirs ( & repo, name, * oid)) 1552. collect (); 1553assert_eq! ( collapsed, [ "a/b/c" , "x" ]); 1554Ok (()) 1555} 1556 1557# [ test ] 1558fn readme_lookup_prefers_markdown_ignores_case_and_skips_directories () ->Result <()> { 1559let root =TempDir :: new ( "readme" ); 1560let repo_path = root. join ( "repo" ); 1561init_repo ( & repo_path) ?; 1562 fs:: write ( repo_path. join ( "readme.txt" ), "txt" ) ?; 1563 fs:: write ( repo_path. join ( "README" ), "bare" ) ?; 1564 fs:: write ( repo_path. join ( "ReadMe.MD" ), "md" ) ?; 1565commit ( & repo_path, "first" ) ?; 1566let repo = gix:: open ( & repo_path) ?; 1567let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1568let ( name, data) =find_readme ( & repo, & entries). unwrap (); 1569assert_eq! (( name. as_str (), data. as_slice ()), ( "ReadMe.MD" , b"md" . as_slice ())); 1570 1571 fs:: remove_file ( repo_path. join ( "ReadMe.MD" )) ?; 1572 fs:: create_dir ( repo_path. join ( "readme.md" )) ?; 1573 fs:: write ( repo_path. join ( "readme.md/keep" ), "" ) ?; 1574commit ( & repo_path, "second" ) ?; 1575let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1576let ( name, data) =find_readme ( & repo, & entries). unwrap (); 1577assert_eq! (( name. as_str (), data. as_slice ()), ( "README" , b"bare" . as_slice ())); 1578Ok (()) 1579} 1580 1581# [ test ] 1582fn refs_skip_non_commit_tags_and_let_branches_shadow_tags () ->Result <()> { 1583let root =TempDir :: new ( "refs" ); 1584let repo_path = root. join ( "repo" ); 1585init_repo ( & repo_path) ?; 1586 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1587commit ( & repo_path, "first" ) ?; 1588git ( & repo_path, & [ "tag" , "main" ]) ?; 1589git ( & repo_path, & [ "tag" , "v1" ]) ?; 1590let blob =git ( & repo_path, & [ "rev-parse" , "HEAD:file" ]) ?; 1591git ( & repo_path, & [ "tag" , "blobtag" , & blob]) ?; 1592 1593let repo = gix:: open ( & repo_path) ?; 1594let mut tips: Vec < _ > =list_refs ( & repo) ? 1595. into_iter () 1596. map ( |tip|( tip. kind , tip. name )) 1597. collect (); 1598 tips. sort_by ( |a, b| a. 1 . cmp ( & b. 1 )); 1599assert_eq! ( tips, [( RefKind :: Branch , "main" . into ()), ( RefKind :: Tag , "v1" . into ())]); 1600Ok (()) 1601} 1602 1603# [ test ] 1604fn head_tip_follows_head_and_falls_back_to_any_branch () ->Result <()> { 1605let root =TempDir :: new ( "head" ); 1606let repo_path = root. join ( "repo" ); 1607init_repo ( & repo_path) ?; 1608 fs:: write ( repo_path. join ( "file" ), "" ) ?; 1609commit ( & repo_path, "first" ) ?; 1610git ( & repo_path, & [ "branch" , "aaa" ]) ?; 1611let repo = gix:: open ( & repo_path) ?; 1612let tips =list_refs ( & repo) ?; 1613assert_eq! ( head_tip ( & repo, & tips). unwrap (). name , "main" ); 1614 1615git ( & repo_path, & [ "checkout" , "-q" , "--detach" ]) ?; 1616let repo = gix:: open ( & repo_path) ?; 1617assert_eq! ( head_tip ( & repo, & tips). unwrap (). kind , RefKind :: Branch ); 1618 1619git ( & repo_path, & [ "symbolic-ref" , "HEAD" , "refs/heads/unborn" ]) ?; 1620let repo = gix:: open ( & repo_path) ?; 1621assert_eq! ( head_tip ( & repo, & tips). unwrap (). kind , RefKind :: Branch ); 1622Ok (()) 1623} 1624 1625# [ test ] 1626fn markdown_never_passes_through_raw_html () { 1627let mut hl =Highlighter :: new ( grammar_cache ()); 1628let html =markdown ( 1629& mut hl, 1630"<script>alert(1)</script>\n\n\ 1631text <b onclick=\"x\">bold</b>\n\n\ 1632```no-such-lang\n<img src=x onerror=alert(1)>\n```\n" , 1633 |_, _|None , 1634); 1635assert! ( html. contains ( "<script>" ), "{html}" ); 1636for forbiddenin [ "<script" , "<b " , "<img" ] { 1637assert! ( !html. contains ( forbidden), "{forbidden} leaked into {html}" ); 1638} 1639} 1640 1641# [ test ] 1642fn typst_readme_requires_feature () ->Result <()> { 1643let root =TempDir :: new ( "typst-feature" ); 1644let repo_path = root. join ( "repo" ); 1645init_repo ( & repo_path) ?; 1646 fs:: write ( repo_path. join ( "README.TYP" ), "= Title" ) ?; 1647commit ( & repo_path, "typst readme" ) ?; 1648let repo = gix:: open ( & repo_path) ?; 1649let entries =collect_entries ( & repo. head_commit () ?. tree () ?) ?; 1650assert_eq! ( find_readme ( & repo, & entries). is_some (), cfg! ( feature ="typst" )); 1651assert_eq! ( super :: is_typst ( "README.TYP" ), cfg! ( feature ="typst" )); 1652Ok (()) 1653} 1654 1655# [ test ] 1656# [ cfg ( not ( feature = "typst" ))] 1657fn typst_disabled_falls_back_to_escaped_source () { 1658let mut hl =Highlighter :: new ( grammar_cache ()); 1659let html =typst ( & mut hl, "<script> #image(\"pic.png\")" , |_, _|panic! ( "Typst disabled" )); 1660assert! ( html. starts_with ( "<pre><script>" ), "{html}" ); 1661assert! ( !html. contains ( "<img" ), "{html}" ); 1662} 1663 1664# [ test ] 1665# [ cfg ( feature = "typst" )] 1666fn typst_renders_markup_and_blocks () { 1667let mut hl =Highlighter :: new ( grammar_cache ()); 1668let html =typst ( & mut hl, "= Heading\n\n*bold* _italic_ https://typst.org \\# ~\n\n```\nfirst\nsecond\n```\n\n- one\n - nested\n- two\n\n+ numbered\n\n/ Term: Description" , |_, _|None ); 1669for expectedin [ "<h1>Heading</h1>" , "<strong>bold</strong>" , "<em>italic</em>" , "<a href=\"https://typst.org\">https://typst.org</a>" , "first\nsecond" , "<ul>" , "<ol>" , "<dl>" , "<dt>Term</dt>" ] { 1670assert! ( html. contains ( expected), "missing {expected}: {html}" ); 1671} 1672for forbiddenin [ "<p><h" , "<p><pre" , "<p><ul" , "*bold*" , "_italic_" ] { 1673assert! ( !html. contains ( forbidden), "{forbidden}: {html}" ); 1674} 1675assert_eq! ( html. matches ( "<ul>" ). count (), 2 , "{html}" ); 1676} 1677 1678# [ test ] 1679# [ cfg ( feature = "typst" )] 1680fn typst_images_resolve_and_escape () { 1681let mut hl =Highlighter :: new ( grammar_cache ()); 1682let html =typst ( & mut hl, "#image(\"../pic.png\", alt: \"A & B\", width: 50%)" , |url, image|{ 1683assert! ( image); 1684assert_eq! ( url, "../pic.png" ); 1685Some ( "/raw/revision/pic.png?x=1&y=2" . into ()) 1686}); 1687assert! ( html. contains ( "<img src=\"/raw/revision/pic.png?x=1&y=2\" alt=\"A & B\">" ), "{html}" ); 1688let html =typst ( & mut hl, "#image(\"https://example.com/pic.png\")" , |_, _|None ); 1689assert! ( html. contains ( "src=\"https://example.com/pic.png\"" ), "{html}" ); 1690} 1691 1692# [ test ] 1693# [ cfg ( feature = "typst" )] 1694fn typst_unsupported_and_invalid_input_falls_back_to_source () { 1695let mut hl =Highlighter :: new ( grammar_cache ()); 1696let html =typst ( & mut hl, "#image(path) $x^2$ #unknown(\"<script>\")" , |_, _|panic! ( "must not evaluate dynamic expressions" )); 1697assert! ( html. contains ( "#image(path)" ), "{html}" ); 1698assert! ( html. contains ( "$x^2$" ), "{html}" ); 1699assert! ( html. contains ( "<script>" ), "{html}" ); 1700assert! ( !html. contains ( "<script>" ), "{html}" ); 1701let html =typst ( & mut hl, "#image(\"unfinished" , |_, _|None ); 1702assert! ( html. starts_with ( "<pre>" ), "{html}" ); 1703} 1704 1705# [ test ] 1706fn reuses_only_matching_blob_pages () ->Result <()> { 1707let root =TempDir :: new ( "blob-reuse" ); 1708let previous = root. join ( "previous" ); 1709let out = root. join ( "out" ); 1710 fs:: create_dir_all ( previous. join ( "blob/src" )) ?; 1711 fs:: write ( previous. join ( "blob/src/main.rs" ), "highlighted" ) ?; 1712let old =BlobVersion { 1713oid : "abc123" . into (), 1714mode : 0o100644 , 1715touched : "first" . into (), 1716}; 1717let previous_versions =BTreeMap :: from ([( "src/main.rs" . into (), old. clone ())]); 1718let changed =BlobVersion { 1719touched : "second" . into (), 1720 ..old. clone () 1721}; 1722let changed_versions =BTreeMap :: from ([( "src/main.rs" . into (), changed)]); 1723let versions =BTreeMap :: from ([( "src/main.rs" . into (), old)]); 1724 1725assert! ( !reuse_blob ( 1726& previous, 1727& previous_versions, 1728& changed_versions, 1729"src/main.rs" , 1730& out, 1731)); 1732assert! ( reuse_blob ( 1733& previous, 1734& previous_versions, 1735& versions, 1736"src/main.rs" , 1737& out, 1738)); 1739assert_eq! ( 1740 fs:: read_to_string ( out. join ( "blob/src/main.rs" )) ?, 1741"highlighted" , 1742); 1743Ok (()) 1744} 1745}