char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
42f80d8
main
1/** 2* where views live. the static page is the fallback and the hash is a delta 3* on it: pieces missing from the hash (revision, path, kind) come from the 4* pathname, so `/u/r/ref/main/blob/x#history` is x's history from main's tip 5* and `/u/r/ref/main/blob/x#<oid>` is x at that commit. 6*/ 7import type { GitRepo } from "./git/repo.ts" ; 8import { objectAt } from "./git/walk.ts" ; 9 10export type ViewKind = "tree" | "blob" | "history" | "commit" | "language" ; 11/** for `language`, `path` is the single language id */ 12export interface View { 13kind :ViewKind ; 14oid :string ; 15path :string []; 16} 17 18/** what the pathname says, plus the ref it stands for */ 19export interface Page { 20base :string ; 21/** the ref's root page: `/u/r/` when that is where we are, else `/u/r/ref/<ref>/` */ 22rootBase :string ; 23/** where the ref's tree and blob pages live: always `/u/r/ref/<ref>/` */ 24refBase :string ; 25tip :string | null ; 26kind :"tree" | "blob" ; 27path :string []; 28} 29 30export interface Site { 31repo :GitRepo ; 32page :Page ; 33name :string ; 34} 35 36const OID = "([0-9a-f]{40}|[0-9a-f]{64})" ; 37export const encodePath = ( path :string []) => path . map ( encodeURIComponent ). join ( "/" ); 38const decodePath = ( path :string ) => path . split ( "/" ). filter ( Boolean ). map ( decodeURIComponent ); 39 40/** `ref` and `tip` come from `<main data-ref data-tip>` */ 41export function pageAt ( pathname :string , ref :string | null , tip :string | null ) :Page { 42const [ user , repo , ...rest ] = pathname . split ( "/" ). filter ( Boolean ); 43const base = `/ ${ user } / ${ repo } ` ; 44if ( rest [ 0 ] !== "ref" ) { 45const refBase = ref ?` ${ base } /ref/ ${ encodePath ( ref . split ( "/" ))} /` :` ${ base } /` ; 46return { base, rootBase :` ${ base } /` , refBase, tip, kind :"tree" , path :[] }; 47} 48// ref names may contain slashes, so the ref runs up to the tree/blob marker 49const marker = rest . findIndex (( seg , i ) => i > 1 && ( seg === "tree" || seg === "blob" )); 50const refBase = ` ${ base } / ${( marker < 0 ? rest : rest . slice ( 0 , marker )). join ( "/" )} /` ; 51const page = { base, rootBase :refBase , refBase, tip}; 52if ( marker < 0 ) return { ...page , kind :"tree" , path :[] }; 53return { ...page , kind :rest [ marker ] as "tree" | "blob" , path :decodePath ( rest . slice ( marker + 1 ). join ( "/" )) }; 54} 55 56export function viewAt ( page :Page , hash :string ) :View | null { 57const match = ( pattern :string ) => hash . match ( new RegExp ( `^# ${ pattern } $` )); 58let m :RegExpMatchArray | null ; 59if (( m = match ( OID ))) return { kind :page . kind , oid :m [ 1 ], path :page . path }; 60if (( m = match ( `commit/ ${ OID } ` ))) return { kind :"commit" , oid :m [ 1 ], path :[] }; 61if (( m = match ( `history(?:/ ${ OID } )?` ))) { 62const oid = m [ 1 ] ?? page . tip ; 63return oid ?{ kind :"history" , oid, path :page . path } :null ; 64} 65if (( m = match ( `language/([a-z0-9-]+)(?:/ ${ OID } )?` ))) { 66const oid = m [ 2 ] ?? page . tip ; 67return oid ?{ kind :"language" , oid, path :[ m [ 1 ]] } :null ; 68} 69if (( m = match ( `(tree|blob|history)/ ${ OID } /(.*)` ))) { 70return { kind :m [ 1 ] as ViewKind , oid :m [ 2 ], path :decodePath ( m [ 3 ]) }; 71} 72if (( m = match ( `tree/ ${ OID } ` ))) return { kind :"tree" , oid :m [ 1 ], path :[] }; 73return null ; 74} 75 76/** 77* the shortest url for `view` that is still a real page without JS: the 78* static page itself when it shows the same thing, else the closest static 79* page with the difference in the hash. `atTip` is what `view.path` is at 80* the page's tip, if anything; hash-only results keep the current pathname. 81*/ 82export function canonical ( page :Page , view :View , atTip :"tree" | "blob" | null ) :string { 83const { kind, oid, path} = view ; 84const rev = oid === page . tip ?"" :`/ ${ oid } ` ; 85const pageFor = ( kind :"tree" | "blob" ) => 86kind === "blob" 87 ?` ${ page . refBase } blob/ ${ encodePath ( path )} ` 88 :path . length > 0 89 ?` ${ page . refBase } tree/ ${ encodePath ( path )} /` 90 :page . rootBase ; 91switch ( kind ) { 92case "commit" : 93return `#commit/ ${ oid } ` ; 94case "language" : 95return ` ${ page . rootBase } #language/ ${ path [ 0 ]} ${ rev } ` ; 96case "history" : 97return atTip ?` ${ pageFor ( atTip )} #history ${ rev } ` :`#history/ ${ oid } / ${ encodePath ( path )} ` ; 98default : 99return atTip === kind 100 ?` ${ pageFor ( kind )} ${ oid === page . tip ? "" : `# ${ oid } ` } ` 101 :`# ${ kind } / ${ oid } / ${ encodePath ( path )} ` ; 102} 103} 104 105export async function kindAtTip ( site :Site , path :string []) :Promise < "tree" | "blob" | null > { 106if ( ! site . page . tip ) return null ; 107if ( path . length === 0 ) return "tree" ; 108const commit = await site . repo . commit ( site . page . tip ); 109const object = await objectAt ( site . repo , commit . tree , path ); 110if ( ! object ) return null ; 111const kind = object . mode & 0o170000 ; 112return kind === 0o040000 ?"tree" :kind === 0o160000 ?null :"blob" ; 113} 114 115export async function href ( site :Site , view :View ) :Promise < string > { 116const needsTip = view . kind === "tree" || view . kind === "blob" || view . kind === "history" ; 117return canonical ( site . page , view , needsTip ?await kindAtTip ( site , view . path ) :null ); 118}