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