This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / api / repoIndex.ts
8.1 kB 249 lines
1import { error } from "@sveltejs/kit"; 2import { ClientResponseError, createBobbinClient } from "$lib/api/client"; 3import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot"; 4import { 5 branches as knotMirrorBranches, 6 createKnotMirrorClient, 7 languages as knotMirrorLanguages, 8 log as knotMirrorLog, 9 tags as knotMirrorTags, 10 tree as knotMirrorTree 11} from "$lib/api/knotmirror"; 12import { parallel } from "$lib/api/load"; 13import { 14 branchesFor, 15 logFor, 16 sortTreeEntries, 17 tagsByCommitHash, 18 tagsFor, 19 toBranchSummary, 20 toCommitSummary, 21 toTagSummary, 22 toTreeCommitSummary, 23 toTreeEntrySummary 24} from "$lib/api/repo"; 25import { renderDocument } from "$lib/markup"; 26import type { LanguageSlice, RepoInfo } from "$lib/components/repo/types"; 27import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; 28 29// `/tree/{ref}` is this same page at another ref, so they share a load 30 31const COMMIT_LIMIT = 10; 32const BRANCH_LIMIT = 5; 33const TAG_LIMIT = 5; 34// a knot only ever lists 100 refs, so any total we get is really a minimum 35const REF_LIMIT = 100; 36 37export interface RepoParent { 38 publicConfig: { bobbinUrl: string; knotMirrorUrl: string; camoEnabled: boolean }; 39 repo: RepoInfo; 40} 41 42export interface RepoLoadEvent { 43 fetch: typeof globalThis.fetch; 44 url: URL; 45} 46 47interface RepoDataSource { 48 tree: (ref: string, path?: string) => Promise<Tree.$output>; 49 log: (ref: string, limit: number) => Promise<Awaited<ReturnType<typeof logFor>>>; 50 branches: (limit: number) => Promise<Awaited<ReturnType<typeof branchesFor>>>; 51 tags: (limit: number) => Promise<Awaited<ReturnType<typeof tagsFor>>>; 52 languages: (ref: string) => Promise<{ languages?: { name: string; size: number }[] }>; 53} 54 55const orNull = <T>(promise: Promise<T>): Promise<T | null> => promise.catch(() => null); 56 57interface Attempt<T> { 58 value: T | null; 59 error: unknown | null; 60} 61 62const attempt = <T>(promise: Promise<T>): Promise<Attempt<T>> => 63 promise.then( 64 (value) => ({ value, error: null }), 65 (error) => ({ value: null, error }) 66 ); 67 68const isUnsupported = (cause: unknown): boolean => 69 cause instanceof ClientResponseError && cause.status === 404; 70 71export const classifyRepoAvailability = ( 72 attempts: readonly { value: unknown | null; error: unknown | null }[] 73) => { 74 const needsUpgrade = attempts.every( 75 (result) => result.value === null && isUnsupported(result.error) 76 ); 77 return { 78 needsUpgrade, 79 knotUnreachable: !needsUpgrade && attempts.every((result) => result.value === null) 80 }; 81}; 82 83const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { 84 const sized = languages.filter((language) => language.size > 0); 85 const total = sized.reduce((sum, language) => sum + language.size, 0); 86 if (total === 0) return []; 87 88 const slices = sized.map((language) => { 89 const share = (language.size / total) * 100; 90 return { name: language.name, share, percentage: Math.floor(share) }; 91 }); 92 93 const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0); 94 [...slices] 95 .sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share) 96 .slice(0, Math.max(0, short)) 97 .forEach((slice) => { 98 slice.percentage += 1; 99 }); 100 101 return slices.sort((a, b) => b.share - a.share); 102}; 103 104const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ 105 branches: branches.map((branch) => branch.name), 106 tags: tags.map((tag) => tag.name), 107 capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT 108}); 109 110const renderReadme = ( 111 readme: { filename: string; contents: string } | null, 112 parent: RepoParent, 113 event: RepoLoadEvent, 114 ref: string, 115 dir?: string 116) => 117 readme 118 ? renderDocument(readme.filename, readme.contents, { 119 repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, 120 ref, 121 dir, 122 host: event.url.host, 123 camo: parent.publicConfig.camoEnabled 124 }) 125 : Promise.resolve(null); 126 127// the knot sends a readme with empty fields when a directory has none 128const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { 129 const readme = tree?.readme; 130 return readme?.filename ? readme : null; 131}; 132 133const repoDataSource = (event: RepoLoadEvent, parent: RepoParent): RepoDataSource => { 134 if (parent.publicConfig.knotMirrorUrl && parent.repo.repoDid) { 135 const ctx = createKnotMirrorClient(parent.publicConfig.knotMirrorUrl, event.fetch); 136 const repo = parent.repo.repoDid; 137 return { 138 tree: (ref, path) => knotMirrorTree(ctx, { repo, ref, path }), 139 log: (ref, limit) => knotMirrorLog(ctx, { repo, ref, limit }), 140 branches: (limit) => knotMirrorBranches(ctx, { repo, limit }), 141 tags: (limit) => knotMirrorTags(ctx, { repo, limit }), 142 languages: (ref) => knotMirrorLanguages(ctx, { repo, ref }) 143 }; 144 } 145 146 const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 147 const repo = parent.repo.uri; 148 return { 149 tree: (ref, path) => knotTree(ctx, { repo, ref, path }), 150 log: (ref, limit) => logFor(ctx, repo, ref, limit), 151 branches: (limit) => branchesFor(ctx, repo, limit), 152 tags: (limit) => tagsFor(ctx, repo, limit), 153 languages: (ref) => knotLanguages(ctx, { repo, ref }) 154 }; 155}; 156 157export interface RepoIndexOptions { 158 requireRef?: boolean; 159} 160 161export const loadRepoIndex = async ( 162 event: RepoLoadEvent, 163 parent: RepoParent, 164 ref: string, 165 // a ref from the url has to resolve or a typo looks like a repo with no 166 // files. the default branch renders whatever the knot managed to answer 167 { requireRef = false }: RepoIndexOptions = {} 168) => { 169 const source = repoDataSource(event, parent); 170 171 // each list falls back on its own, so half a page still renders 172 const results = await parallel({ 173 tree: attempt(source.tree(ref)), 174 log: attempt(source.log(ref, COMMIT_LIMIT)), 175 branches: attempt(source.branches(REF_LIMIT)), 176 tags: attempt(source.tags(REF_LIMIT)), 177 languages: attempt(source.languages(ref)) 178 }); 179 180 const branches = (results.branches.value?.branches ?? []).map(toBranchSummary); 181 const tags = (results.tags.value?.tags ?? []).map(toTagSummary); 182 const commits = (results.log.value?.commits ?? []).map(toCommitSummary); 183 const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary)); 184 185 const languages = toLanguageSlices(results.languages.value?.languages ?? []); 186 187 const readme = readmeOf(results.tree.value); 188 const readmeHtml = await renderReadme(readme, parent, event, ref); 189 190 const contentAttempts = [results.tree, results.log, results.branches]; 191 const { needsUpgrade, knotUnreachable } = classifyRepoAvailability(contentAttempts); 192 const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0; 193 194 // there are refs but not this one, so it is not a real ref. an empty repo has 195 // no refs at all and still gets a page 196 if (requireRef && results.tree.value === null && branches.length > 0) { 197 error(404, `${ref} does not exist in this repository`); 198 } 199 200 return { 201 ref, 202 isEmpty, 203 needsUpgrade, 204 knotUnreachable, 205 files, 206 readme, 207 readmeHtml, 208 commits, 209 tagsByCommit: tagsByCommitHash(commits, tags), 210 totalCommits: results.log.value?.total ?? commits.length, 211 branches: branches.slice(0, BRANCH_LIMIT), 212 totalBranches: branches.length, 213 tags: tags.slice(0, TAG_LIMIT), 214 totalTags: tags.length, 215 // the switcher needs every ref, not just the visible slice 216 refs: refNames(branches, tags), 217 languages 218 }; 219}; 220 221export const loadRepoTree = async ( 222 event: RepoLoadEvent, 223 parent: RepoParent, 224 ref: string, 225 path: string 226) => { 227 const source = repoDataSource(event, parent); 228 229 // the tree is the whole page here, so a miss is just a 404 230 const tree = await orNull(source.tree(ref, path)); 231 const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary)); 232 // git cannot store an empty directory. so nothing here means the path is a 233 // file, or was never there 234 if (tree === null || files.length === 0) { 235 error(404, `${path} does not exist at ${ref}`); 236 } 237 238 const readme = readmeOf(tree); 239 const readmeHtml = await renderReadme(readme, parent, event, ref, path); 240 241 return { 242 ref, 243 path, 244 files, 245 readme, 246 readmeHtml, 247 lastCommit: tree.lastCommit ? toTreeCommitSummary(tree.lastCommit) : null 248 }; 249};