char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
f92b8f4
main
1// a plain repo page needs none of the git machinery, so it all lives behind 2// dynamic imports: the object store loads on first use, each view in its own chunk 3import type { GitRepo , TransferProgress } from "./git/repo.ts" ; 4import type { Commit } from "./git/types.ts" ; 5import { PAGE_SIZE , paginate } from "./pagination.tsx" ; 6import { canonical , pageAt , type Site , viewAt } from "./route.ts" ; 7import { humanSize , identityDate } from "./util.ts" ; 8 9const segments = location . pathname . split ( "/" ). filter ( Boolean ); 10const reserved = new Set ([ "css" , "js" , "-" ]); 11 12interface LoadingProgress { 13bar :HTMLProgressElement ; 14amount :Text ; 15transfers :Map < number , { loaded :number ; total ?:number }>; 16show :() => void ; 17} 18 19function initRouter ( repoName :string , base :string ) { 20const main = document . querySelector ( "main" ); 21if ( ! main ) return ; 22const ref = main . dataset . ref ?? null ; 23const tip = main . dataset . tip ?? null ; 24const loadedPathname = location . pathname ; 25let loading :LoadingProgress | null = null ; 26const owners = new Map < number , LoadingProgress >(); 27const showProgress = ( transfer :TransferProgress ) => { 28if ( transfer . phase === "start" && loading ) { 29owners . set ( transfer . id , loading ); 30loading . show (); 31} 32const owner = owners . get ( transfer . id ); 33if ( ! owner ) return ; 34owner . transfers . set ( transfer . id , { loaded :transfer . loaded , total :transfer . total }); 35if ( transfer . phase === "done" ) owners . delete ( transfer . id ); 36if ( owner !== loading ) return ; 37 38let loaded = 0 ; 39let total = 0 ; 40let unknown = false ; 41for ( const item of owner . transfers . values ()) { 42loaded += item . loaded ; 43if ( item . total === undefined ) unknown = true ; 44else total += item . total ; 45} 46if ( unknown ) owner . bar . removeAttribute ( "value" ); 47else { 48owner . bar . max = Math . max ( total , 1 ); 49owner . bar . value = loaded ; 50} 51owner . amount . data = unknown 52 ?` ${ humanSize ( loaded )} ` 53 :` ${ humanSize ( loaded )} / ${ humanSize ( total )} ` ; 54}; 55let transfers = new AbortController (); 56let repo :Promise < GitRepo > | undefined ; 57const gitRepo = () => 58repo ??= import ( "./git/repo.ts" ). then (({ GitRepo, httpFetcher}) => 59new GitRepo ( httpFetcher ( base , showProgress , () => transfers . signal ), `sorcery ${ base } ` ) 60); 61 62// the static page's own hooks; its path exists at the tip by construction 63const page = pageAt ( loadedPathname , ref , tip ); 64if ( tip ) { 65for ( const heading of main . querySelectorAll < HTMLElement >( "h2.log-heading" )) { 66const target = canonical ( page , { kind :"history" , oid :tip , path :[] }, "tree" ); 67heading . replaceChildren (< a href = { target }>{ heading . textContent }</ a >); 68} 69for ( const actions of main . querySelectorAll < HTMLElement >( ".topbar > .actions" )) { 70const target = canonical ( page , { kind :"history" , oid :tip , path :page . path }, page . kind ); 71actions . prepend (< a href = { target }> history </ a >); 72} 73for ( const item of main . querySelectorAll < HTMLElement >( ".languages li[data-language]" )) { 74const label = item . querySelector ( "span" ) ! ; 75const target = canonical ( page , { kind :"language" , oid :tip , path :[ item . dataset . language ! ] }, null ); 76label . replaceWith (< a href = { target }>{[ ...label . childNodes ]}</ a >); 77} 78} 79for ( const span of main . querySelectorAll < HTMLElement >( "[data-commit]" )) { 80const oid = span . dataset . commit ! ; 81span . replaceWith (< a class = { span . className } href = { `#commit/${oid } `}>{ span . textContent }</ a >); 82} 83// Plain-mode blob pages ship unhighlighted source; data-hl carries the path. 84for ( const pre of main . querySelectorAll < HTMLPreElement >( "pre.src[data-hl]" )) { 85const targets = pre . querySelectorAll ( ".code-text" ); 86const source = [ ...targets ]. map ( line => line . textContent ?? "" ). join ( "\n" ); 87void import ( "./highlight.ts" ) 88. then ( module => module . highlightedLines ( pre . dataset . hl ! , source )) 89. then ( highlighted => { 90if ( highlighted === null ) return ; 91for ( const [ i , line ] of highlighted . entries ()) targets [ i ]?. replaceChildren ( ...line ); 92}) 93. catch ( err => console . warn ( "arborium:" , err )); 94} 95initLogPagination ( gitRepo , main ); 96 97// the repo header stays; everything else makes way for the view 98const original = ([ ...main . children ] as HTMLElement []). filter ( el => ! el . matches ( "header.repo" )); 99let view :HTMLElement | null = null ; 100let current = "" ; 101 102const route = async () => { 103if ( location . href === current ) return ; 104current = location . href ; 105// the previous view's in-flight fetches would only steal bandwidth now 106transfers . abort (); 107transfers = new AbortController (); 108const target = location . href ; 109const parsed = viewAt ( pageAt ( location . pathname , ref , tip ), location . hash ); 110loading = null ; 111if ( ! parsed ) { 112// only the loaded page's static content is here to show 113if ( location . pathname !== loadedPathname ) return location . reload (); 114view ?. remove (); 115view = null ; 116for ( const el of original ) el . style . display = "" ; 117return ; 118} 119const close = < a class = "back" href = { location . pathname }> ← close < / a>; 120const status = (< p class = "meta" > loading { parsed . kind } { parsed. oid . slice ( 0 , 12 )} … < / p>) as HTMLElement; 121const nextView = ( 122< section class = { parsed . kind === "commit" ?"commit-view" :"historical-view" }> 123{ close} 124{ status} 125< / section> 126) as HTMLElement ; 127let shown = false ; 128const show = () => { 129if ( shown || location . href !== target ) return ; 130shown = true ; 131for ( const el of original ) el . style . display = "none" ; 132view ?. remove (); 133view = nextView ; 134main . append ( view ); 135}; 136const bar = document . createElement ( "progress" ); 137const amount = document . createTextNode ( "" ); 138status . append ( " " , bar , amount ); 139loading = { bar , amount , transfers : new Map (), show }; 140try { 141const site : Site = { repo : await gitRepo (), page : pageAt ( location . pathname , ref , tip ), name : repoName }; 142const rendered = parsed . kind === "commit" 143? await ( await import ( "./commit.tsx" )). commitView ( site , parsed ) 144: parsed . kind === "tree" 145? await ( await historicalView ()). treeView ( site , parsed ) 146: parsed . kind === "blob" 147? await ( await historicalView ()). blobView ( site , parsed ) 148: parsed . kind === "language" 149? await ( await historicalView ()). languageView ( site , parsed ) 150: await ( await historicalView ()). historyView ( site , parsed ); 151if ( location . href === target ) { 152loading = null ; 153nextView . replaceChildren ( close , ... rendered ); 154show (); 155} 156} catch ( err ) { 157if ( location . href === target ) { 158loading = null ; 159nextView . replaceChildren ( close , < p class = "meta" > failed to load { parsed . kind } : { String ( err )}< /p>); 160show (); 161} 162} 163}; 164// canonical links may change the pathname: take those in-app rather than 165// loading the fallback page, unless the link *is* the fallback page 166document . addEventListener ( "click" , event => { 167const anchor = ( event . target as Element ). closest ( "a[href]" ); 168if ( 169! ( anchor instanceof HTMLAnchorElement ) || event . defaultPrevented || event . button !== 0 170|| event . metaKey || event . ctrlKey || event . shiftKey || event . altKey || anchor . target 171) return ; 172const url = new URL ( anchor . href ); 173if ( url . origin !== location . origin || url . pathname === location . pathname || ! url . hash ) return ; 174if ( ! url . pathname . startsWith ( ` ${ base } /` )) return ; 175event . preventDefault (); 176history . pushState ( null , "" , url ); 177void route (); 178}); 179addEventListener ( "popstate" , route ); 180addEventListener ( "hashchange" , route ); 181void route (); 182} 183 184async function * commitLog ( gitRepo : () => Promise < GitRepo >, frontier : string []) : AsyncGenerator < Commit > { 185const [ repo , { log }] = await Promise . all ([ gitRepo (), import ( "./git/walk.ts" )]); 186yield * log ( repo , frontier , PAGE_SIZE ); 187} 188 189function initLogPagination ( gitRepo : () => Promise < GitRepo >, main : HTMLElement ) { 190for ( const control of main . querySelectorAll < HTMLElement >( "[data-log-frontier]" )) { 191const sibling = control . previousElementSibling ; 192if ( ! ( sibling instanceof HTMLOListElement )) continue ; 193const items = [ ... sibling . children ] as HTMLElement []; 194const frontier = control . dataset . logFrontier ! . split ( " " ); 195paginate < Commit >({ 196list : sibling , 197control , 198seed : { items , keys : items . map ( li => li . dataset . oid ! ) }, 199open : () => commitLog ( gitRepo , frontier ), 200key : commit => commit . oid , 201render : commit => { 202const href = `#commit/ ${ commit . oid } ` ; 203const item = (< li dataset = {{ oid : commit . oid }} />) as HTMLLIElement ; 204if ( commit . changeId ) { 205item . append (< a class = "cid" href = { href }>{ commit . changeId . slice ( 0 , 8 )}</ a >, " " ); 206} 207item . append ( 208< a class = "sha" href = { href }>{ commit . oid . slice ( 0 , 7 )}</ a >, 209" " , 210< span class = "who" > 211< span >{ commit . author . name }</ span > 212< time >{ identityDate ( commit . author ). slice ( 0 , 10 )}</ time > 213< / span > , 214< span class =" msg " >{ commit . message . split ( "\n" )[ 0 ]}</ span > , 215); 216return item; 217}, 218}); 219} 220} 221 222// dynamic imports are memoized by the module loader, so no caching needed 223const historicalView = () => import ( "./historical.tsx" ); 224 225if ( segments . length >= 2 && ! reserved . has ( segments [ 0 ])) { 226initRouter ( segments [ 1 ], `/ ${ segments [ 0 ]} / ${ segments [ 1 ]} ` ); 227}