This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / api / blob.ts
5.3 kB 175 lines
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};