import { error } from "@sveltejs/kit"; import { ClientResponseError, createBobbinClient } from "$lib/api/client"; import { languages as knotLanguages, tree as knotTree } from "$lib/api/knot"; import { branches as knotMirrorBranches, createKnotMirrorClient, languages as knotMirrorLanguages, log as knotMirrorLog, tags as knotMirrorTags, tree as knotMirrorTree } from "$lib/api/knotmirror"; 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"; import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; // `/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; knotMirrorUrl: string; camoEnabled: boolean }; repo: RepoInfo; } export interface RepoLoadEvent { fetch: typeof globalThis.fetch; url: URL; } interface RepoDataSource { tree: (ref: string, path?: string) => Promise; log: (ref: string, limit: number) => Promise>>; branches: (limit: number) => Promise>>; tags: (limit: number) => Promise>>; languages: (ref: string) => Promise<{ languages?: { name: string; size: number }[] }>; } 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 const classifyRepoAvailability = ( attempts: readonly { value: unknown | null; error: unknown | null }[] ) => { const needsUpgrade = attempts.every( (result) => result.value === null && isUnsupported(result.error) ); return { needsUpgrade, knotUnreachable: !needsUpgrade && attempts.every((result) => result.value === 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; }; const repoDataSource = (event: RepoLoadEvent, parent: RepoParent): RepoDataSource => { if (parent.publicConfig.knotMirrorUrl && parent.repo.repoDid) { const ctx = createKnotMirrorClient(parent.publicConfig.knotMirrorUrl, event.fetch); const repo = parent.repo.repoDid; return { tree: (ref, path) => knotMirrorTree(ctx, { repo, ref, path }), log: (ref, limit) => knotMirrorLog(ctx, { repo, ref, limit }), branches: (limit) => knotMirrorBranches(ctx, { repo, limit }), tags: (limit) => knotMirrorTags(ctx, { repo, limit }), languages: (ref) => knotMirrorLanguages(ctx, { repo, ref }) }; } const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const repo = parent.repo.uri; return { tree: (ref, path) => knotTree(ctx, { repo, ref, path }), log: (ref, limit) => logFor(ctx, repo, ref, limit), branches: (limit) => branchesFor(ctx, repo, limit), tags: (limit) => tagsFor(ctx, repo, limit), languages: (ref) => knotLanguages(ctx, { repo, ref }) }; }; 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 source = repoDataSource(event, parent); // each list falls back on its own, so half a page still renders const results = await parallel({ tree: attempt(source.tree(ref)), log: attempt(source.log(ref, COMMIT_LIMIT)), branches: attempt(source.branches(REF_LIMIT)), tags: attempt(source.tags(REF_LIMIT)), languages: attempt(source.languages(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 { needsUpgrade, knotUnreachable } = classifyRepoAvailability(contentAttempts); 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.value === null && branches.length > 0) { error(404, `${ref} does not exist in this repository`); } return { ref, isEmpty, needsUpgrade, knotUnreachable, 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 source = repoDataSource(event, parent); // the tree is the whole page here, so a miss is just a 404 const tree = await orNull(source.tree(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 }; };