Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315import { error } from "@sveltejs/kit";import { ClientResponseError, createBobbinClient } from "$lib/api/client";import { resolveEmailToDid } from "$lib/api/emailDid";import { resolveMiniDoc } from "$lib/api/identity";import { branch as gitBranch, branches as gitBranches, gitTarget, type GitTarget, languages as gitLanguages, log as gitLog, tags as gitTags, tree as gitTree} from "$lib/api/gitclient";import { toMirrorCommitSummary } from "$lib/api/gitmirror";import { orNull, parallel, stream } from "$lib/api/load";import { branchSummaries, sortTreeEntries, tagsByCommitHash, toTagSummary, toTreeCommitSummary, toTreeEntrySummary} from "$lib/api/repo";import { importState, type ImportState } from "$lib/api/repo";import { renderDocument } from "$lib/markup";import type { CommitSummary, LanguageSlice, RepoInfo } from "$lib/components/repo/types";// `/tree/{ref}` is this same page at another ref, so they share a load
export const COMMIT_LIMIT = 10;export const BRANCH_LIMIT = 5;export const TAG_LIMIT = 5;// a knot only ever lists 100 refs, so any total we get is really a minimumexport const REF_LIMIT = 100;
export interface RepoParent { publicConfig: { bobbinUrl: string; camoEnabled: boolean }; repo: RepoInfo | Promise<RepoInfo>;}
export interface RepoLoadEvent { fetch: typeof globalThis.fetch; url: URL;}
interface Attempt<T> { value: T | null; error: unknown | null;}
const attempt = <T>(promise: Promise<T>): Promise<Attempt<T>> => 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" | "importing" | "import-failed";
export const classifyRepoAvailability = ( attempts: readonly { value: unknown | null; error: unknown | null }[]): Exclude<RepoAvailability, "empty" | "importing" | "import-failed"> => { if (attempts.every((result) => result.value === null && isUnsupported(result.error))) { return "needs-upgrade"; } return attempts.every((result) => result.value === null) ? "unreachable" : "ok";};
export const withImportAvailability = ( availability: RepoAvailability, status: ImportState | null): RepoAvailability => { if (availability === "ok" || availability === "needs-upgrade") { return availability; } if (!status) { return availability; } return status.content === "failed" ? "import-failed" : "importing";};
export 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);};
export const repoBranches = async (git: GitTarget) => branchSummaries(await gitBranches(git, REF_LIMIT));
const refNames = (branches: { name: string }[], tags: { name: string }[]) => ({ branches: branches.map((branch) => branch.name), tags: tags.map((tag) => tag.name)});
export const renderReadme = ( readme: { filename: string; contents: string } | null, repo: RepoInfo, publicConfig: { camoEnabled: boolean }, event: RepoLoadEvent, ref: string, dir?: string) => readme ? renderDocument(readme.filename, readme.contents, { repo: `${repo.ownerHandle}/${repo.name}`, ref, dir, host: event.url.host, camo: publicConfig.camoEnabled }) : Promise.resolve(null);
// the knot sends a readme with empty fields when a directory has noneconst readmeOf = (tree: { readme?: { filename: string; contents: string } } | null) => { const readme = tree?.readme; return readme?.filename ? readme : null;};
export interface RepoIndexOptions { requireRef?: boolean; importStatus?: boolean;}
// for the handle and the row reads like the rest of the site. a plain git// email that belongs to a verified signup resolves to that same did first, so// those rows get the identical treatment.export const withAuthorHandles = async ( commits: CommitSummary[], bobbinUrl: string, fetch: typeof globalThis.fetch): Promise<CommitSummary[]> => { let hydrated = commits; const emails = [ ...new Set(commits.flatMap((commit) => (commit.authorDid ? [] : [commit.authorEmail]))) ]; if (emails.length > 0) { // the lookups are best-effort: a kv/route failure keeps plain emails const emailDids = (await orNull(resolveEmailToDid(fetch, emails))) ?? new Map(); if (emailDids.size > 0) { hydrated = commits.map((commit) => { if (commit.authorDid) return commit; const did = emailDids.get(commit.authorEmail); return did ? { ...commit, authorDid: did } : commit; }); } }
const dids = [...new Set(hydrated.flatMap((commit) => commit.authorDid ?? []))]; if (dids.length === 0) return hydrated;
const ctx = createBobbinClient({ serviceUrl: bobbinUrl, fetch }); const docs = await Promise.all(dids.map((did) => orNull(resolveMiniDoc(ctx, did)))); const handles = new Map<string, string>(); for (const doc of docs) { if (doc && !doc.handle.endsWith(".invalid")) handles.set(doc.did, doc.handle); }
return hydrated.map((commit) => commit.authorDid ? { ...commit, authorHandle: handles.get(commit.authorDid) } : commit );};
export const loadRepoIndex = ( 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, importStatus: withImportStatus = false }: RepoIndexOptions = {}) => { const repoP = Promise.resolve(parent.repo);
// each list falls back on its own, so half a page still renders const content = repoP.then(async (repo) => { const targetRef = ref ?? (await repo.defaultBranch); const git = gitTarget(parent.publicConfig, repo, event.fetch);
const results = await parallel({ tree: attempt(gitTree(git, { ref: targetRef })), log: attempt(gitLog(git, { ref: targetRef, limit: COMMIT_LIMIT })), branch: attempt(gitBranch(git, targetRef)), branches: attempt(gitBranches(git, REF_LIMIT)), tags: attempt(gitTags(git, REF_LIMIT)) });
const branches = results.branches.value ? branchSummaries(results.branches.value) : []; const tags = (results.tags.value?.tags ?? []).map(toTagSummary); const commits = await withAuthorHandles( (results.log.value?.commits ?? []).map(toMirrorCommitSummary), parent.publicConfig.bobbinUrl, event.fetch ); const files = sortTreeEntries((results.tree.value?.files ?? []).map(toTreeEntrySummary));
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, `${targetRef} does not exist in this repository`); }
return { ref: targetRef, availability, files, readme: readmeOf(results.tree.value), commits, tagsByCommit: tagsByCommitHash(commits, tags), totalCommits: results.branch.value?.totalCommits ?? commits.length, branches: branches.slice(0, BRANCH_LIMIT), totalBranches: results.branches.value?.total ?? branches.length, tags: tags.slice(0, TAG_LIMIT), totalTags: results.tags.value?.total ?? tags.length, // the switcher needs every ref, not just the visible slice refs: refNames(branches, tags) }; });
const languages = repoP.then(async (repo) => { const targetRef = ref ?? (await repo.defaultBranch); const git = gitTarget(parent.publicConfig, repo, event.fetch); const result = await attempt(gitLanguages(git, targetRef)); return toLanguageSlices(result.value?.languages ?? []); });
const readmeHtml = Promise.all([repoP, content]).then(([repo, resolved]) => renderReadme(resolved.readme, repo, parent.publicConfig, event, resolved.ref) );
const importStatusP = withImportStatus ? repoP.then(async (repo): Promise<ImportState | null> => { if (!repo.importSource) return null; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const state = await orNull(importState(ctx, repo)); return state ? { ...state, source: repo.importSource } : null; }) : Promise.resolve(null);
return { ref: ref ?? "", content: stream(content), languages: stream(languages), readmeHtml: stream(readmeHtml), importStatus: stream(importStatusP) };};
export const loadRepoTree = ( event: RepoLoadEvent, parent: RepoParent, ref: string, path: string) => { const repoP = Promise.resolve(parent.repo);
const tree = repoP.then(async (repo) => { const git = gitTarget(parent.publicConfig, repo, event.fetch); const result = await orNull(gitTree(git, { ref, path })); const files = sortTreeEntries((result?.files ?? []).map(toTreeEntrySummary)); // git cannot store an empty directory. so nothing here means the path is a // file, or was never there if (result === null || files.length === 0) { error(404, `${path} does not exist at ${ref}`); }
return { files, readme: readmeOf(result), lastCommit: result.lastCommit ? toTreeCommitSummary(result.lastCommit) : null }; });
const readmeHtml = Promise.all([repoP, tree]).then(([repo, resolved]) => renderReadme(resolved.readme, repo, parent.publicConfig, event, ref, path) );
return { ref, path, tree: stream(tree), readmeHtml: stream(readmeHtml) };};