import { error } from "@sveltejs/kit"; import { ClientResponseError } from "$lib/api/client"; import { blob as gitBlob, gitTarget } from "$lib/api/gitclient"; import { toHttpError } from "$lib/api/load"; import { toTreeCommitSummary, type CommitSummary } from "$lib/api/repo"; import { isMarkdownFile } from "$lib/markup"; import { renderReadme, type RepoLoadEvent, type RepoParent } from "./repoIndex"; export type BlobKind = "code" | "markup" | "svg" | "image" | "video" | "submodule" | "other"; export type BlobViewMode = "code" | "rendered"; export interface RepoBlobView { ref: string; path: string; kind: BlobKind; sizeLabel: string | null; lines: number | null; contents: string | null; renderedHtml: string | null; fileTooLarge: boolean; defaultView: BlobViewMode; submodule: { name: string; url: string } | null; lastCommit: CommitSummary | null; } // same 1MiB inline cap as the appview's maxBlobSize export const MAX_INLINE_SIZE = 1 << 20; // go-humanize's Bytes export const formatBytes = (bytes: number): string => { if (bytes < 1000) return `${bytes} B`; const units = ["kB", "MB", "GB", "TB", "PB", "EB"]; let value = bytes; let unit = "B"; for (const next of units) { if (value < 1000) break; value /= 1000; unit = next; } let rendered = value < 10 ? value.toFixed(1) : String(Math.round(value)); // rounding can spill over the boundary ("1000 kB"), promote to the // next unit and render again ("1.0 MB") if (Number.parseFloat(rendered) >= 1000 && unit !== "EB") { value /= 1000; unit = units[units.indexOf(unit) + 1]; rendered = value < 10 ? value.toFixed(1) : String(Math.round(value)); } return `${rendered} ${unit}`; }; // textual application/* types (json, toml, ...) arrive with isBinary=false // instead of a text/* mime export const classifyBlob = (blob: { path: string; mimeType?: string; isBinary?: boolean; submodule?: unknown; }): BlobKind => { if (blob.submodule) return "submodule"; const mediaType = (blob.mimeType ?? "").split(";")[0].trim().toLowerCase(); if (mediaType === "image/svg+xml") return "svg"; if (mediaType.startsWith("image/")) return "image"; if (mediaType.startsWith("video/")) return "video"; if (blob.isBinary === false || mediaType.startsWith("text/")) { return isMarkdownFile(blob.path) ? "markup" : "code"; } return "other"; }; export const hasTextView = (kind: BlobKind): boolean => kind === "code" || kind === "markup" || kind === "svg"; export const hasRenderedView = (kind: BlobKind): boolean => kind === "markup" || kind === "svg"; // a trailing newline terminates the last line instead of starting a new one const countLines = (text: string): number => { if (text === "") return 0; const lines = text.split("\n").length; return text.endsWith("\n") ? lines - 1 : lines; }; export const loadRepoBlob = async ( event: RepoLoadEvent, parent: RepoParent, ref: string, path: string ): Promise => { const git = gitTarget(parent.publicConfig, parent.repo, event.fetch); // the knot 404s directories and missing paths alike // knot2's repo_blob answers 413 over its serving limit instead of // sending size json (knot-xrpc reads.rs) const output = await gitBlob(git, { ref, path }, MAX_INLINE_SIZE).catch( (cause: unknown): Awaited> | null => { if (cause instanceof ClientResponseError && cause.status === 404) { error(404, `${path} does not exist at ${ref}`); } if ( cause instanceof ClientResponseError && (cause.status === 413 || cause.error === "BlobTooLarge") ) { return null; } return toHttpError(cause, "Could not load file"); } ); if (output === null) { return { ref, path, kind: "other", sizeLabel: null, lines: null, contents: null, renderedHtml: null, fileTooLarge: true, defaultView: "code", submodule: null, lastCommit: null }; } const kind = classifyBlob(output); const size = output.size ?? null; const fileTooLarge = output.fileTooLarge === true || (hasTextView(kind) && size !== null && size > MAX_INLINE_SIZE); const contents = hasTextView(kind) && !fileTooLarge && output.encoding === "utf-8" ? (output.content ?? null) : null; const renderedHtml = kind === "markup" && contents !== null ? await renderReadme( { filename: path, contents }, parent, event, ref, path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : undefined ) : null; // renderReadme gives up over its source limit, fall back to code const viewKind = kind === "markup" && renderedHtml === null ? "code" : kind; const lastCommit = output.lastCommit ? toTreeCommitSummary({ hash: output.lastCommit.hash, message: output.lastCommit.message, when: output.lastCommit.when, author: output.lastCommit.author }) : null; return { ref, path, kind: viewKind, sizeLabel: size === null ? null : formatBytes(size), lines: contents !== null ? countLines(contents) : null, contents, renderedHtml, fileTooLarge, defaultView: hasRenderedView(viewKind) ? event.url.searchParams.has("code") ? "code" : "rendered" : "code", submodule: output.submodule ? { name: output.submodule.name, url: output.submodule.url } : null, lastCommit }; };