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