char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
c166af0
main
1// snapshot views: a commit's tree, one of its blobs, or its files of one 2// language. only the tree is rendered at the tip: the static blob page is 3// richer than what the client can make 4import type { Commit , TreeEntry } from "./git/types.ts" ; 5import { objectAt } from "./git/walk.ts" ; 6import { detectLanguage , languageGroup } from "./languages.ts" ; 7import { canonical , encodePath , isPageView , type Site , type View } from "./route.ts" ; 8import { commitTime , humanSize , isImage , looksBinary , looksGenerated , plural , utf8 } from "./util.ts" ; 9 10const TREE = 0o040000 ; 11const SYMLINK = 0o120000 ; 12const GITLINK = 0o160000 ; 13const kindOf = ( entry :TreeEntry ) => entry . mode & 0o170000 ; 14 15export async function kindAtTip ( site :Site , path :string []) :Promise < "tree" | "blob" | null > { 16if ( ! site . page . tip ) return null ; 17if ( path . length === 0 ) return "tree" ; 18const commit = await site . repo . commit ( site . page . tip ); 19const object = await objectAt ( site . repo , commit . tree , path ); 20if ( ! object ) return null ; 21const kind = object . mode & 0o170000 ; 22return kind === TREE ?"tree" :kind === GITLINK ?null :"blob" ; 23} 24 25export async function href ( site :Site , view :View ) :Promise < string > { 26const needsTip = view . kind === "tree" || view . kind === "blob" || view . kind === "history" ; 27return canonical ( site . page , view , needsTip ?await kindAtTip ( site , view . path ) :null ); 28} 29 30async function entriesAt ( site :Site , commit :Commit , path :string []) :Promise < TreeEntry []> { 31const object = await objectAt ( site . repo , commit . tree , path ); 32if ( ! object || ( object . mode & 0o170000 ) !== TREE ) throw new Error ( ` ${ path . join ( "/" )} is not a tree` ); 33return site . repo . tree ( object . oid ); 34} 35 36/** `a/b/c` when `a` holds only `b`, which holds only `c`: a chain of lone 37* directories reads better as one link than as three clicks */ 38async function collapseLoneDirs ( site :Site , entry :TreeEntry ) :Promise < string []> { 39const names = [ entry . name ]; 40let oid = entry . oid ; 41for (;;) { 42const [ only , ...rest ] = await site . repo . tree ( oid ); 43if ( rest . length > 0 || ! only || kindOf ( only ) !== TREE ) return names ; 44names . push ( only . name ); 45oid = only . oid ; 46} 47} 48 49/** the same shape as the static topbar. the ref switcher is baked into the 50* static page and only applies at the tip, so it is borrowed from there; 51* elsewhere the commit panel above says which commit this is */ 52export async function topbar ( site :Site , view :View , stats :string [], actions :Node [] = []) :Promise < HTMLElement > { 53const { name} = site ; 54const { kind, oid, path} = view ; 55const crumbs = (< nav class = "crumbs" / >) as HTMLElement ; 56const isRoot = kind !== "language" && kind !== "commit" && path . length === 0 ; 57if ( isRoot ) crumbs . append (< span class = "cur" >{ name}< / span>); 58else crumbs . append (< a href = { await href ( site , { kind :"tree" , oid, path :[] })}>{ name }</ a >); 59if ( kind === "language" ) crumbs . append (< span class = "label" > language :</ span >, " " , < span class = "cur" >{ path [ 0 ]}</ span >); 60else if ( kind === "commit" ) crumbs . append (< span class = "label" > commit :</ span >, " " , < span class = "cur" >{ oid . slice ( 0 , 7 )}</ span >); 61else { 62for ( let i = 0 ; i < path . length ; i ++ ) { 63crumbs . append ( " / " ); 64if ( i < path . length - 1 ) { 65crumbs . append (< a href = { await href ( site , { kind :"tree" , oid, path :path . slice ( 0 , i + 1 ) })}>{ path [ i ]}</ a >); 66} else { 67crumbs . append (< span class = "cur" >{ path [ i ]}</ span >); 68if ( kind === "tree" ) crumbs . append ( " /" ); 69} 70} 71} 72 73if ( kind === "tree" || kind === "blob" ) { 74actions . unshift (< a href = { await href ( site , { kind :"history" , oid, path})}> history </ a >); 75} 76const switcher = isPageView ( site . page , view ) 77 ?document . querySelector ( "main > .topbar > .switcher" )?. cloneNode ( true ) as HTMLElement | undefined 78 :undefined ; 79return ( 80< header class = "topbar" > 81{ switcher} 82{ crumbs} 83< span class = "view-stats" >{ stats. map ( stat => < span >{ stat }</ span >)}</ span > 84< span class = "actions" >{ actions}< / span> 85</ header > 86) as HTMLElement ; 87} 88 89function commitPanel ( commit :Commit ) :HTMLElement { 90return ( 91< p class = "snapshot-commit" > 92< span class = "commit-author" >{ commit . author . name }</ span > 93< a class = "commit-message" href = { `#commit/${commit . oid } `}>{ commit . message . split ( "\n" )[ 0 ]}</ a > 94< a class = "snapshot-sha sha" href = { `#commit/${commit . oid } `}>{ commit . oid . slice ( 0 , 7 )}</ a > 95{ commitTime ( commit . author )} 96< / p> 97) as HTMLElement ; 98} 99 100export async function treeView ( site : Site , view : View ) : Promise < Node []> { 101const { oid , path } = view ; 102await site . repo . prefetch ([ oid ], { smart : "tree" }); 103const commit = await site . repo . commit ( oid ); 104const entries = await entriesAt ( site , commit , path ); 105// directories first, then byte order, as git and the static pages 106entries . sort (( a , b ) => Number ( kindOf ( a ) !== TREE ) - Number ( kindOf ( b ) !== TREE ) || ( a . name < b . name ? - 1 : a . name > b . name ? 1 : 0 )); 107const folders = entries . filter ( entry => kindOf ( entry ) === TREE ). length ; 108const files = entries . length - folders ; 109const stats = [ folders ? plural ( folders , "folder" ) : "" , files ? plural ( files , "file" ) : "" ]. filter ( Boolean ); 110const rows = await Promise . all ( entries . map ( async entry => { 111const kind = kindOf ( entry ); 112if ( kind === GITLINK ) return { name : ` ${ entry . name } @ ${ entry . oid . slice ( 0 , 7 )} ` }; 113const names = kind === TREE ? await collapseLoneDirs ( site , entry ) : [ entry . name ]; 114const name = names . join ( "/" ) + ( kind === TREE ? "/" : kind === SYMLINK ? "@" : "" ); 115return { name , href : await href ( site , { kind : kind === TREE ? "tree" : "blob" , oid , path : [ ... path , ... names ] }) }; 116})); 117return [ 118commitPanel ( commit ), 119await topbar ( site , view , stats ), 120< h2 class = "file-heading" > files < /h2>, 121listing ( rows ), 122]; 123} 124 125function listing ( rows : Array <{ name : string ; href ?: string }>) : HTMLTableElement { 126const table = (< table class = "list" / >) as HTMLTableElement ; 127for ( const { name , href } of rows ) { 128table . append (< tr >< td >{ href ? < a href = { href }>{ name }</ a > : name }</ td ></ tr >); 129} 130return table ; 131} 132 133/** every file at the commit classified as `language`, flat: a filtered tree 134* would need the filter carried through every directory link */ 135export async function languageView ( site : Site , view : View ) : Promise < Node []> { 136const { oid , path : [ language ] } = view ; 137await site . repo . prefetch ([ oid ], { smart : "tree" }); 138const commit = await site . repo . commit ( oid ); 139const paths : string [][] = []; 140// the smart tree bundle caps at 128 objects, so load further 141// levels' subtrees in batches rather than one at a time 142let level = [{ oid : commit . tree , prefix : [] as string [] }]; 143while ( level . length > 0 ) { 144await site . repo . prefetch ( level . map ( tree => tree . oid ), { depth : 0 }); 145const next : typeof level = []; 146for ( const { oid : tree , prefix } of level ) { 147for ( const entry of await site . repo . tree ( tree )) { 148const path = [ ... prefix , entry . name ]; 149const kind = kindOf ( entry ); 150if ( kind === TREE ) { 151// trailing slash so the directory is tested as a parent, not a file 152if ( ! looksGenerated ( ` ${ path . join ( "/" )} /` )) next . push ({ oid : entry . oid , prefix : path }); 153} else if ( kind !== SYMLINK && kind !== GITLINK && ! looksGenerated ( path . join ( "/" ))) { 154const grammar = detectLanguage ( entry . name ); 155if ( grammar && languageGroup ( grammar ) === language ) paths . push ( path ); 156} 157} 158} 159level = next ; 160} 161paths . sort (( a , b ) => ( a . join ( "/" ) < b . join ( "/" ) ? - 1 : 1 )); 162const rows = await Promise . all ( paths . map ( async path => ({ 163name : path . join ( "/" ), 164href : await href ( site , { kind : "blob" , oid , path }), 165}))); 166return [ 167commitPanel ( commit ), 168await topbar ( site , view , [ plural ( paths . length , "file" )]), 169< h2 class = "file-heading" > files < /h2>, 170listing ( rows ), 171]; 172} 173 174export async function blobView ( site : Site , view : View ) : Promise < Node []> { 175const { oid , path } = view ; 176if ( path . length === 0 ) throw new Error ( "blob path is empty" ); 177const commit = await site . repo . commit ( oid ); 178const object = await objectAt ( site . repo , commit . tree , path ); 179if ( ! object || ( object . mode & 0o170000 ) === TREE || ( object . mode & 0o170000 ) === GITLINK ) { 180throw new Error ( ` ${ path . join ( "/" )} is not a blob` ); 181} 182const data = await site . repo . blob ( object . oid ); 183const rawURL = ` ${ site . page . base } /raw/ ${ object . oid } / ${ encodePath ( path )} ` ; 184const raw = < a class = "raw" href = { rawURL }> raw < /a>; 185const symlink = ( object . mode & 0o170000 ) === SYMLINK ; 186const image = ! symlink && isImage ( path . at ( - 1 ) ! ); 187const renderable = ! symlink && ! image && ! looksBinary ( data ) && data . length <= 1 << 20 ; 188const text = renderable ? utf8 . decode ( data ) : "" ; 189const lines = text . split ( "\n" ); 190if ( lines . at ( - 1 ) === "" ) lines . pop (); 191const stats = symlink 192? [ `symlink → ${ utf8 . decode ( data )} ` ] 193: renderable 194? [ humanSize ( data . length ), plural ( lines . length , "line" )] 195: [ image ? "image" : looksBinary ( data ) ? "binary file" : "large file" , humanSize ( data . length )]; 196const head = [ commitPanel ( commit ), await topbar ( site , view , stats , [ raw ])]; 197if ( image ) return [ ... head , < div class = "image-preview" >< img src = { rawURL } alt = { path . join ( "/" )} />< /div>]; 198if ( ! renderable ) return symlink ? head : [ ... head , 199< div class = "file-download" > 200< p > This file cannot be previewed. </ p > 201< a href = { rawURL } download = { path . at ( - 1 ) ! }> download raw file < /a> 202</ div >, 203]; 204 205const source = ( 206< pre class = "code src" >{ lines . map (( line , i ) => 207< span class = "code-line" > 208< span class = "ln" >{ String ( i + 1 )}< / span> 209< span class = "code-text" >{ line }< /span> 210{ i < lines . length - 1 ? < span class = "code-break" >{ "\n" }< /span> : ""} 211</ span > 212)}</ pre > 213) as HTMLPreElement ; 214void import ( "./highlight.ts" ) 215. then ( module => module . highlightedLines ( path . join ( "/" ), text )) 216. then ( highlighted => { 217if ( highlighted === null ) return ; 218const targets = source . querySelectorAll ( ".code-text" ); 219for ( const [ i , line ] of highlighted . entries ()) targets [ i ]?. replaceChildren ( ... line ); 220}) 221. catch ( err => console . warn ( "arborium:" , err )); 222return [ ... head , source ]; 223}