import { error } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot"; import { parallel } from "$lib/api/load"; import { branchesFor, logFor, sortTreeEntries, tagsByCommitHash, tagsFor, 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 const REF_LIMIT = 100; export interface RepoParent { publicConfig: { bobbinUrl: string; camoEnabled: boolean }; repo: RepoInfo; } export interface RepoLoadEvent { fetch: typeof globalThis.fetch; url: URL; } const orNull = (promise: Promise): Promise => promise.catch(() => null); 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 }); 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 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 }: { requireRef?: boolean } = {} ) => { const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const repo = parent.repo.uri; // each list falls back on its own, so half a page still renders const results = await parallel({ tree: orNull(knotTree(ctx, { repo, ref })), log: orNull(logFor(ctx, repo, ref, COMMIT_LIMIT)), branches: orNull(branchesFor(ctx, repo, REF_LIMIT)), tags: orNull(tagsFor(ctx, repo, REF_LIMIT)), languages: orNull(knotLanguages(ctx, { repo, ref })) }); const branches = (results.branches?.branches ?? []).map(toBranchSummary); const tags = (results.tags?.tags ?? []).map(toTagSummary); const commits = (results.log?.commits ?? []).map(toCommitSummary); const files = sortTreeEntries((results.tree?.files ?? []).map(toTreeEntrySummary)); const languages = toLanguageSlices(results.languages?.languages ?? []); const readme = readmeOf(results.tree); const readmeHtml = await renderReadme(readme, parent, event, ref); const knotUnreachable = results.tree === null && results.log === null && results.branches === null; const isEmpty = !knotUnreachable && files.length === 0 && branches.length === 0; // 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 === null && branches.length > 0) { error(404, `${ref} does not exist in this repository`); } return { ref, isEmpty, knotUnreachable, files, readme, readmeHtml, commits, tagsByCommit: tagsByCommitHash(commits, tags), totalCommits: results.log?.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 ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const repo = parent.repo.uri; // the tree is the whole page here, so a miss is just a 404 const tree = await orNull(knotTree(ctx, { repo, 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 }; };