import { error } from "@sveltejs/kit"; import { ClientResponseError } from "$lib/api/client"; import { branches as gitBranches, gitTarget, languages as gitLanguages, log as gitLog, tags as gitTags, tree as gitTree } from "$lib/api/gitclient"; import { parallel } from "$lib/api/load"; import { sortTreeEntries, tagsByCommitHash, toBranchSummary, toCommitSummary, toTagSummary, toTreeCommitSummary, toTreeEntrySummary } from "$lib/api/repo"; import { renderDocument } from "$lib/markup"; import type { LanguageSlice, RepoInfo } from "$lib/components/repo/types"; // `/tree/{ref}` is this same page at another ref, so they share a load const COMMIT_LIMIT = 10; const BRANCH_LIMIT = 5; const TAG_LIMIT = 5; // a knot only ever lists 100 refs, so any total we get is really a minimum export const REF_LIMIT = 100; export interface RepoParent { publicConfig: { bobbinUrl: string; knotMirrorUrl: string; camoEnabled: boolean }; repo: RepoInfo; } export interface RepoLoadEvent { fetch: typeof globalThis.fetch; url: URL; } const orNull = (promise: Promise): Promise => promise.catch(() => null); interface Attempt { value: T | null; error: unknown | null; } const attempt = (promise: Promise): Promise> => promise.then( (value) => ({ value, error: null }), (error) => ({ value: null, error }) ); const isUnsupported = (cause: unknown): boolean => cause instanceof ClientResponseError && cause.status === 404; export type RepoAvailability = "ok" | "empty" | "needs-upgrade" | "unreachable"; export const classifyRepoAvailability = ( attempts: readonly { value: unknown | null; error: unknown | null }[] ): Exclude => { if (attempts.every((result) => result.value === null && isUnsupported(result.error))) { return "needs-upgrade"; } return attempts.every((result) => result.value === null) ? "unreachable" : "ok"; }; const toLanguageSlices = (languages: { name: string; size: number }[]): LanguageSlice[] => { const sized = languages.filter((language) => language.size > 0); const total = sized.reduce((sum, language) => sum + language.size, 0); if (total === 0) return []; const slices = sized.map((language) => { const share = (language.size / total) * 100; return { name: language.name, share, percentage: Math.floor(share) }; }); const short = 100 - slices.reduce((sum, slice) => sum + slice.percentage, 0); [...slices] .sort((a, b) => (b.share % 1) - (a.share % 1) || b.share - a.share) .slice(0, Math.max(0, short)) .forEach((slice) => { slice.percentage += 1; }); return slices.sort((a, b) => b.share - a.share); }; const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ branches: branches.map((branch) => branch.name), tags: tags.map((tag) => tag.name), capped: branches.length >= REF_LIMIT || tags.length >= REF_LIMIT }); export const renderReadme = ( readme: { filename: string; contents: string } | null, parent: RepoParent, event: RepoLoadEvent, ref: string, dir?: string ) => readme ? renderDocument(readme.filename, readme.contents, { repo: `${parent.repo.ownerHandle}/${parent.repo.name}`, ref, dir, host: event.url.host, camo: parent.publicConfig.camoEnabled }) : Promise.resolve(null); // the knot sends a readme with empty fields when a directory has none const readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { const readme = tree?.readme; return readme?.filename ? readme : null; }; export interface RepoIndexOptions { requireRef?: boolean; } export const loadRepoIndex = async ( event: RepoLoadEvent, parent: RepoParent, ref: string, // a ref from the url has to resolve or a typo looks like a repo with no // files. the default branch renders whatever the knot managed to answer { requireRef = false }: RepoIndexOptions = {} ) => { const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); // each list falls back on its own, so half a page still renders const results = await parallel({ tree: attempt(gitTree(git, { ref })), log: attempt(gitLog(git, { ref, limit: COMMIT_LIMIT })), branches: attempt(gitBranches(git, REF_LIMIT)), tags: attempt(gitTags(git, REF_LIMIT)), languages: attempt(gitLanguages(git, ref)) }); const branches = (results.branches.value?.branches ?? []).map(toBranchSummary); const tags = (results.tags.value?.tags ?? []).map(toTagSummary); const commits = (results.log.value?.commits ?? []).map(toCommitSummary); const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary)); const languages = toLanguageSlices(results.languages.value?.languages ?? []); const readme = readmeOf(results.tree.value); const readmeHtml = await renderReadme(readme, parent, event, ref); const contentAttempts = [results.tree, results.log, results.branches]; const knot = classifyRepoAvailability(contentAttempts); const availability: RepoAvailability = knot === "ok" && files.length === 0 && branches.length === 0 ? "empty" : knot; // there are refs but not this one, so it is not a real ref. an empty repo has // no refs at all and still gets a page if (requireRef && results.tree.value === null && branches.length > 0) { error(404, `${ref} does not exist in this repository`); } return { ref, availability, files, readme, readmeHtml, commits, tagsByCommit: tagsByCommitHash(commits, tags), totalCommits: results.log.value?.total ?? commits.length, branches: branches.slice(0, BRANCH_LIMIT), totalBranches: branches.length, tags: tags.slice(0, TAG_LIMIT), totalTags: tags.length, // the switcher needs every ref, not just the visible slice refs: refNames(branches, tags), languages }; }; export const loadRepoTree = async ( event: RepoLoadEvent, parent: RepoParent, ref: string, path: string ) => { const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); // the tree is the whole page here, so a miss is just a 404 const tree = await orNull(gitTree(git, { ref, path })); const files = sortTreeEntries((tree?.files ?? []).map(toTreeEntrySummary)); // git cannot store an empty directory. so nothing here means the path is a // file, or was never there if (tree === null || files.length === 0) { error(404, `${path} does not exist at ${ref}`); } const readme = readmeOf(tree); const readmeHtml = await renderReadme(readme, parent, event, ref, path); return { ref, path, files, readme, readmeHtml, lastCommit: tree.lastCommit ? toTreeCommitSummary(tree.lastCommit) : null }; };