forks/sorcery
git clone https://git.gae.moe/forks/sorcery
c276aa4
grace-patches
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 , isPageView , type Page , pageAt , pageView , type Site , type View , type ViewKind , viewAt , viewKey , withinRef } from "./route.ts" ; 7import { commitTime , humanSize , relativizeTime , timeAgo } from "./util.ts" ; 8 9if ( Promise . withResolvers === undefined ) await import ( "./with-resolvers.ts" ); 10 11const segments = location . pathname . split ( "/" ). filter ( Boolean ); 12const reserved = new Set ([ "css" , "js" , "-" ]); 13 14interface LoadingProgress { 15bar :HTMLProgressElement ; 16amount :Text ; 17transfers :Map < number , { loaded :number ; total ?:number }>; 18show :() => void ; 19} 20 21function initRouter ( repoName :string , base :string ) { 22const main = document . querySelector ( "main" ); 23if ( ! main ) return ; 24const ref = main . dataset . ref ?? null ; 25const tip = main . dataset . tip ?? null ; 26const loadedPathname = location . pathname ; 27let loading :LoadingProgress | null = null ; 28const owners = new Map < number , LoadingProgress >(); 29const showProgress = ( transfer :TransferProgress ) => { 30if ( transfer . phase === "start" && loading ) { 31owners . set ( transfer . id , loading ); 32loading . show (); 33} 34const owner = owners . get ( transfer . id ); 35if ( ! owner ) return ; 36owner . transfers . set ( transfer . id , { loaded :transfer . loaded , total :transfer . total }); 37if ( transfer . phase === "done" ) owners . delete ( transfer . id ); 38if ( owner !== loading ) return ; 39 40let loaded = 0 ; 41let total = 0 ; 42let unknown = false ; 43for ( const item of owner . transfers . values ()) { 44loaded += item . loaded ; 45if ( item . total === undefined ) unknown = true ; 46else total += item . total ; 47} 48if ( unknown ) owner . bar . removeAttribute ( "value" ); 49else { 50owner . bar . max = Math . max ( total , 1 ); 51owner . bar . value = loaded ; 52} 53owner . amount . data = unknown 54 ?` ${ humanSize ( loaded )} ` 55 :` ${ humanSize ( loaded )} / ${ humanSize ( total )} ` ; 56}; 57let transfers = new AbortController (); 58let repo :Promise < GitRepo > | undefined ; 59const gitRepo = () => 60repo ??= import ( "./git/repo.ts" ). then (({ GitRepo, httpFetcher}) => 61new GitRepo ( httpFetcher ( base , showProgress , () => transfers . signal ), `sorcery ${ base } ` ) 62); 63 64// a static page's own hooks; its path exists at the tip by construction 65const enhance = ( root :ParentNode , page :Page ) => { 66if ( page . tip ) { 67for ( const heading of root . querySelectorAll < HTMLElement >( "h2.log-heading" )) { 68const target = canonical ( page , { kind :"history" , oid :page . tip , path :[] }, "tree" ); 69heading . replaceChildren (< a href = { target }>{ heading . textContent }</ a >); 70} 71for ( const actions of root . querySelectorAll < HTMLElement >( ".topbar > .actions" )) { 72const target = canonical ( page , { kind :"history" , oid :page . tip , path :page . path }, page . kind ); 73actions . prepend (< a href = { target }> history </ a >); 74} 75for ( const item of root . querySelectorAll < HTMLElement >( ".languages li[data-language]" )) { 76const label = item . querySelector ( "span" ) ! ; 77const target = canonical ( page , { kind :"language" , oid :page . tip , path :[ item . dataset . language ! ] }, null ); 78label . replaceWith (< a href = { target }>{[ ...label . childNodes ]}</ a >); 79} 80} 81for ( const span of root . querySelectorAll < HTMLElement >( "[data-commit]" )) { 82const oid = span . dataset . commit ! ; 83span . replaceWith (< a class = { span . className } href = { `#commit/${oid } `}>{ span . textContent }</ a >); 84} 85// Plain-mode blob pages ship unhighlighted source; data-hl carries the path. 86for ( const pre of root . querySelectorAll < HTMLPreElement >( "pre.src[data-hl]" )) { 87const targets = pre . querySelectorAll ( ".code-text" ); 88const source = [ ...targets ]. map ( line => line . textContent ?? "" ). join ( "\n" ); 89void import ( "./highlight.ts" ) 90. then ( module => module . highlightedLines ( pre . dataset . hl ! , source )) 91. then ( highlighted => { 92if ( highlighted === null ) return ; 93for ( const [ i , line ] of highlighted . entries ()) targets [ i ]?. replaceChildren ( ...line ); 94}) 95. catch ( err => console . warn ( "arborium:" , err )); 96} 97initLogPagination ( gitRepo , root ); 98}; 99const loaded = pageAt ( loadedPathname , ref , tip ); 100enhance ( main , loaded ); 101 102// the server's blob pages come highlighted, with markdown rendered and 103// line anchors; cheaper to fetch whole than to render here 104const staticPage = async ( pathname :string , page :Page ) :Promise < Node []> => { 105const response = await fetch ( pathname , { signal :transfers . signal }); 106if ( ! response . ok ) throw new Error ( ` ${ response . status } ${ response . statusText } ` ); 107const fetched = new DOMParser (). parseFromString ( await response . text (), "text/html" ). querySelector ( "main" ); 108if ( ! fetched ) throw new Error ( "not a repo page" ); 109enhance ( fetched , page ); 110return [ ...fetched . children ]. filter ( el => ! el . matches ( "header.repo, noscript" )); 111}; 112 113// the repo header stays; everything else makes way for the view. the 114// original content is detached rather than hidden: fetched pages reuse 115// its ids (line anchors, the markdown toggle) 116const original = [ ...main . children ]; 117const header = original . filter ( el => el . matches ( "header.repo" )); 118// the static content is the loaded page's own view, and stands in for it 119// wherever else that view is wanted 120const staticKey = viewKey ( pageView ( loaded )); 121let wanted = staticKey ; 122 123const route = async () => { 124// other refs' tips are unknown here; their pages have their own 125if ( location . pathname !== loadedPathname && ! withinRef ( loaded , location . pathname )) return location . reload (); 126const page = pageAt ( location . pathname , ref , tip ); 127const view = viewAt ( page , location . hash ); 128const key = viewKey ( view ); 129if ( key === wanted ) return ; 130wanted = key ; 131// the previous view's in-flight fetches would only steal bandwidth now 132transfers . abort (); 133transfers = new AbortController (); 134loading = null ; 135if ( ! view || key === staticKey ) { 136main . replaceChildren ( ...original ); 137return ; 138} 139// a view the hash adds closes back to the page; the page's own view has the crumbs 140const head = isPageView ( page , view ) ?[] :[< a class = "back" href = { location . pathname }> ← close < / a>]; 141const status = (< p class = "meta" > loading { view . kind } { view. oid . slice ( 0 , 12 )} … < / p>) as HTMLElement; 142const nextView = ( 143< section class = { `${view . kind } -view`}> 144{ head} 145{ status} 146< / section> 147) as HTMLElement ; 148const show = () => { 149if ( wanted !== key || main . contains ( nextView )) return ; 150main . replaceChildren ( ... header , nextView ); 151}; 152const bar = document . createElement ( "progress" ); 153const amount = document . createTextNode ( "" ); 154status . append ( " " , bar , amount ); 155loading = { bar , amount , transfers : new Map (), show }; 156try { 157// directories render from tree objects that are small and likely cached 158const rendered = view . kind === "blob" && isPageView ( page , view ) 159? await staticPage ( location . pathname , page ) 160: await render [ view . kind ]({ repo : await gitRepo (), page , name : repoName }, view ); 161if ( wanted === key ) { 162loading = null ; 163nextView . replaceChildren ( ... head , ... rendered ); 164show (); 165} 166} catch ( err ) { 167if ( wanted === key ) { 168loading = null ; 169nextView . replaceChildren ( ... head , < p class = "meta" > failed to load { view . kind } : { String ( err )}< /p>); 170show (); 171} 172} 173}; 174// links within the ref are taken in-app: the page they name is what the 175// tip renders anyway. same-page hash links are left to the browser so 176// `#L12` still scrolls 177document . addEventListener ( "click" , event => { 178const anchor = ( event . target as Element ). closest ( "a[href]" ); 179if ( 180! ( anchor instanceof HTMLAnchorElement ) || event . defaultPrevented || event . button !== 0 181|| event . metaKey || event . ctrlKey || event . shiftKey || event . altKey || anchor . target 182) return ; 183const url = new URL ( anchor . href ); 184if ( url . origin !== location . origin || ! withinRef ( loaded , url . pathname )) return ; 185if ( url . pathname === location . pathname && url . hash ) return ; 186event . preventDefault (); 187if ( url . href !== location . href ) history . pushState ( null , "" , url ); 188void route (); 189scrollTo ( 0 , 0 ); 190}); 191addEventListener ( "popstate" , route ); 192addEventListener ( "hashchange" , route ); 193void route (); 194} 195 196async function * commitLog ( gitRepo : () => Promise < GitRepo >, frontier : string[]): AsyncGenerator < Commit > { 197const [ repo , { log }] = await Promise . all ([ gitRepo (), import ( "./git/walk.ts" )]); 198yield * log ( repo , frontier , PAGE_SIZE ); 199} 200 201function initLogPagination ( gitRepo : () => Promise < GitRepo >, root : ParentNode ) { 202for ( const control of root . querySelectorAll < HTMLElement >( "[data-log-frontier]" )) { 203const sibling = control . previousElementSibling ; 204if ( ! ( sibling instanceof HTMLOListElement )) continue ; 205const items = [... sibling . children ] as HTMLElement []; 206const frontier = control . dataset . logFrontier ! . split ( " " ); 207paginate < Commit >({ 208list : sibling , 209control, 210seed : { items, keys : items . map ( li => li . dataset . oid ! ) }, 211open : () => commitLog ( gitRepo , frontier ), 212key : commit => commit . oid , 213render : commit => { 214const href = `#commit/ ${ commit . oid } ` ; 215const item = (< li dataset = {{ oid : commit . oid }} />) as HTMLLIElement ; 216if ( commit . changeId ) { 217item . append (< a class = "cid" href = { href }>{ commit . changeId . slice ( 0 , 8 )}</ a >, " " ); 218} 219item . append ( 220< a class = "sha" href = { href }>{ commit . oid . slice ( 0 , 7 )}</ a >, 221" " , 222< span class = "who" > 223< span >{ commit . author . name }</ span > 224{ commitTime ( commit . author )} 225< / span>, 226< span class = "msg" >{ commit . message . split ( "\n" )[ 0 ]}</ span >, 227); 228return item ; 229}, 230}); 231} 232} 233 234// each view is its own chunk; the module loader memoizes the imports 235const render : Record < ViewKind , ( site : Site , view : View ) => Promise < Node []>> = { 236commit : ( site , view ) => import ( "./commit.tsx" ). then ( m => m . commitView ( site , view )), 237history : ( site , view ) => import ( "./history.tsx" ). then ( m => m . historyView ( site , view )), 238tree : ( site , view ) => import ( "./tree.tsx" ). then ( m => m . treeView ( site , view )), 239blob : ( site , view ) => import ( "./tree.tsx" ). then ( m => m . blobView ( site , view )), 240language : ( site , view ) => import ( "./tree.tsx" ). then ( m => m . languageView ( site , view )), 241}; 242 243// pages are rendered ahead of time, so they carry absolute times: the 244// catalog's are relativized outright, commits' only while recent 245for ( const time of document . querySelectorAll < HTMLTimeElement >( "time[datetime]" )) { 246if ( time . closest ( ".catalog" )) { 247time . title = time . textContent ?? "" ; 248time . textContent = timeAgo ( new Date ( time . dateTime )); 249} else { 250relativizeTime ( time ); 251} 252} 253 254if ( segments . length >= 2 && ! reserved . has ( segments [ 0 ])) { 255initRouter ( segments [ 1 ], `/ ${ segments [ 0 ]} / ${ segments [ 1 ]} ` ); 256}