This repository has no description
1import { error } from "@sveltejs/kit";
2import { ClientResponseError } from "$lib/api/client";
3import { blob as gitBlob, gitTarget } from "$lib/api/gitclient";
4import { toHttpError } from "$lib/api/load";
5import { toTreeCommitSummary, type CommitSummary } from "$lib/api/repo";
6import { isMarkdownFile } from "$lib/markup";
7import { renderReadme, type RepoLoadEvent, type RepoParent } from "./repoIndex";
8
9export type BlobKind = "code" | "markup" | "svg" | "image" | "video" | "submodule" | "other";
10
11export type BlobViewMode = "code" | "rendered";
12
13export interface RepoBlobView {
14 ref: string;
15 path: string;
16 kind: BlobKind;
17 sizeLabel: string | null;
18 lines: number | null;
19 contents: string | null;
20 renderedHtml: string | null;
21 fileTooLarge: boolean;
22 defaultView: BlobViewMode;
23 submodule: { name: string; url: string } | null;
24 lastCommit: CommitSummary | null;
25}
26
27// same 1MiB inline cap as the appview's maxBlobSize
28export const MAX_INLINE_SIZE = 1 << 20;
29
30// go-humanize's Bytes
31export const formatBytes = (bytes: number): string => {
32 if (bytes < 1000) return `${bytes} B`;
33 const units = ["kB", "MB", "GB", "TB", "PB", "EB"];
34 let value = bytes;
35 let unit = "B";
36 for (const next of units) {
37 if (value < 1000) break;
38 value /= 1000;
39 unit = next;
40 }
41 let rendered = value < 10 ? value.toFixed(1) : String(Math.round(value));
42 // rounding can spill over the boundary ("1000 kB"), promote to the
43 // next unit and render again ("1.0 MB")
44 if (Number.parseFloat(rendered) >= 1000 && unit !== "EB") {
45 value /= 1000;
46 unit = units[units.indexOf(unit) + 1];
47 rendered = value < 10 ? value.toFixed(1) : String(Math.round(value));
48 }
49 return `${rendered} ${unit}`;
50};
51
52// textual application/* types (json, toml, ...) arrive with isBinary=false
53// instead of a text/* mime
54export const classifyBlob = (blob: {
55 path: string;
56 mimeType?: string;
57 isBinary?: boolean;
58 submodule?: unknown;
59}): BlobKind => {
60 if (blob.submodule) return "submodule";
61 const mediaType = (blob.mimeType ?? "").split(";")[0].trim().toLowerCase();
62 if (mediaType === "image/svg+xml") return "svg";
63 if (mediaType.startsWith("image/")) return "image";
64 if (mediaType.startsWith("video/")) return "video";
65 if (blob.isBinary === false || mediaType.startsWith("text/")) {
66 return isMarkdownFile(blob.path) ? "markup" : "code";
67 }
68 return "other";
69};
70
71export const hasTextView = (kind: BlobKind): boolean =>
72 kind === "code" || kind === "markup" || kind === "svg";
73
74export const hasRenderedView = (kind: BlobKind): boolean => kind === "markup" || kind === "svg";
75
76// a trailing newline terminates the last line instead of starting a new one
77const countLines = (text: string): number => {
78 if (text === "") return 0;
79 const lines = text.split("\n").length;
80 return text.endsWith("\n") ? lines - 1 : lines;
81};
82
83export const loadRepoBlob = async (
84 event: RepoLoadEvent,
85 parent: RepoParent,
86 ref: string,
87 path: string
88): Promise<RepoBlobView> => {
89 const git = gitTarget(parent.publicConfig, parent.repo, event.fetch);
90
91 // the knot 404s directories and missing paths alike
92 // knot2's repo_blob answers 413 over its serving limit instead of
93 // sending size json (knot-xrpc reads.rs)
94 const output = await gitBlob(git, { ref, path }, MAX_INLINE_SIZE).catch(
95 (cause: unknown): Awaited<ReturnType<typeof gitBlob>> | null => {
96 if (cause instanceof ClientResponseError && cause.status === 404) {
97 error(404, `${path} does not exist at ${ref}`);
98 }
99 if (
100 cause instanceof ClientResponseError &&
101 (cause.status === 413 || cause.error === "BlobTooLarge")
102 ) {
103 return null;
104 }
105 return toHttpError(cause, "Could not load file");
106 }
107 );
108
109 if (output === null) {
110 return {
111 ref,
112 path,
113 kind: "other",
114 sizeLabel: null,
115 lines: null,
116 contents: null,
117 renderedHtml: null,
118 fileTooLarge: true,
119 defaultView: "code",
120 submodule: null,
121 lastCommit: null
122 };
123 }
124
125 const kind = classifyBlob(output);
126 const size = output.size ?? null;
127 const fileTooLarge =
128 output.fileTooLarge === true || (hasTextView(kind) && size !== null && size > MAX_INLINE_SIZE);
129
130 const contents =
131 hasTextView(kind) && !fileTooLarge && output.encoding === "utf-8"
132 ? (output.content ?? null)
133 : null;
134
135 const renderedHtml =
136 kind === "markup" && contents !== null
137 ? await renderReadme(
138 { filename: path, contents },
139 parent,
140 event,
141 ref,
142 path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined
143 )
144 : null;
145
146 // renderReadme gives up over its source limit, fall back to code
147 const viewKind = kind === "markup" && renderedHtml === null ? "code" : kind;
148
149 const lastCommit = output.lastCommit
150 ? toTreeCommitSummary({
151 hash: output.lastCommit.hash,
152 message: output.lastCommit.message,
153 when: output.lastCommit.when,
154 author: output.lastCommit.author
155 })
156 : null;
157
158 return {
159 ref,
160 path,
161 kind: viewKind,
162 sizeLabel: size === null ? null : formatBytes(size),
163 lines: contents !== null ? countLines(contents) : null,
164 contents,
165 renderedHtml,
166 fileTooLarge,
167 defaultView: hasRenderedView(viewKind)
168 ? event.url.searchParams.has("code")
169 ? "code"
170 : "rendered"
171 : "code",
172 submodule: output.submodule ? { name: output.submodule.name, url: output.submodule.url } : null,
173 lastCommit
174 };
175};